Compare commits

...

148 Commits

Author SHA1 Message Date
Christian Byrne
cae988effe [backport cloud/1.41] fix: tree explorer row height and width overflow (#10501) (#10924)
Backport of #10501. Clean cherry-pick.

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-06 15:30:04 -07:00
Christian Byrne
ad2612009e [backport cloud/1.41] fix: handle clipboard errors in Copy Image and useCopyToClipboard (#9299) (#10923)
Backport of #9299. Clean cherry-pick.
2026-04-06 15:29:25 -07:00
Christian Byrne
54e488ad7f [backport cloud/1.41] fix: keep job details popover on-screen in sidebar (#9679) (#10922)
Backport of #9679 with useJobDetailsHover composable. Used correct file
versions from merge commit.

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-04-06 15:28:41 -07:00
Arthur R Longbottom
662e5368c1 [backport cloud/1.41] fix: subgraph output slot labels not updating in v2 renderer (#10728)
Backport of #9266 to cloud/1.41.

Cherry-pick of merge commit 5640eb7 with conflict resolution in:
- `useGraphNodeManager.ts` — kept target's `extractVueNodeData` re-call
- `SubgraphNode.ts` — kept target's comment and
`_invalidatePromotedViewsCache`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10728-backport-cloud-1-41-fix-subgraph-output-slot-labels-not-updating-in-v2-renderer-3336d73d365081e587bff11b35972f91)
by [Unito](https://www.unito.io)
2026-03-30 17:03:33 -07:00
jaeone94
5851925937 [backport cloud/1.41] fix: prevent saving active workflow content to inactive tab on close (#10755)
Backport of #10745 to cloud/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10755-backport-cloud-1-41-fix-prevent-saving-active-workflow-content-to-inactive-tab-on-clo-3336d73d365081849837df818e91b09e)
by [Unito](https://www.unito.io)
2026-03-30 13:03:16 -07:00
Christian Byrne
8983fdd49d [backport cloud/1.41] feat: add SHA-256 hashed email to GTM dataLayer for sign_up/login events (#10637)
Backport of #10591 to `cloud/1.41`

Conflict resolution: adapted the two new email-hashing tests to use the
cloud/1.41 test style (direct `new GtmTelemetryProvider()` with inline
config) since the helper functions (`createInitializedProvider`,
`lastDataLayerEntry`) from #9770 were never backported to this branch.

All 6 tests pass locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10637-backport-cloud-1-41-feat-add-SHA-256-hashed-email-to-GTM-dataLayer-for-sign_up-login--3316d73d365081638938f3a5653d972e)
by [Unito](https://www.unito.io)
2026-03-27 19:40:19 -07:00
Comfy Org PR Bot
6869b5c6db [backport cloud/1.41] fix: hide inaccurate resolution subtitle on cloud asset cards (#10605)
Backport of #10602 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10605-backport-cloud-1-41-fix-hide-inaccurate-resolution-subtitle-on-cloud-asset-cards-3306d73d365081178dd0e4a84fc12524)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-26 22:06:59 -07:00
Christian Byrne
e9e3d6d10f [backport cloud/1.41] feat: fire subscription_success telemetry on subscription activation (#10186) (#10593)
Backport of #10186 to cloud/1.41.

Fires a client-side `subscription_success` event to GTM when a
subscription activates, enabling LinkedIn and Meta conversion tracking
tags.

Cherry-picked from merge commit 6c14802.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10593-backport-cloud-1-41-feat-fire-subscription_success-telemetry-on-subscription-activati-3306d73d3650810ea583ce3ae437e199)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-03-26 19:45:29 -07:00
Comfy Org PR Bot
9632e33e39 [backport cloud/1.41] fix: normalize legacy prefixed proxyWidget entries on configure (#10577)
Backport of #10573 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10577-backport-cloud-1-41-fix-normalize-legacy-prefixed-proxyWidget-entries-on-configure-32f6d73d36508179a1e2e2345b307511)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 23:00:29 +00:00
Comfy Org PR Bot
db3801f21b [backport cloud/1.41] fix: create initial workflow tab when persistence is disabled (#10567)
Backport of #10565 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10567-backport-cloud-1-41-fix-create-initial-workflow-tab-when-persistence-is-disabled-32f6d73d365081d9ac10f454fc143ce2)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 11:50:06 -07:00
Comfy Org PR Bot
af9bb6c222 [backport cloud/1.41] fix: repoint ancestor promoted widget bindings when packing nested subgraphs (#10557)
Backport of #10532 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10557-backport-cloud-1-41-fix-repoint-ancestor-promoted-widget-bindings-when-packing-nested-32f6d73d365081968360e2936d7762d1)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-03-26 05:51:30 +00:00
Comfy Org PR Bot
46be292a3d [backport cloud/1.41] fix: App mode - workaround for alt+m producing alt+μ on mac (#10536)
Backport of #10528 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10536-backport-cloud-1-41-fix-App-mode-workaround-for-alt-m-producing-alt-on-mac-32e6d73d365081369e92c8acd0432d6f)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-25 17:13:29 -07:00
pythongosssss
fdd68d4e04 [backport cloud/1.41] fix: App mode - renaming widgets on subgraphs (#10245) (#10507)
## Summary
- Cherry-pick of #10245 onto `cloud/1.41`
- Fixes App mode widget renaming on subgraph nodes by resolving promoted
widget sources without requiring explicit parents

## Test plan
- [ ] Unit tests pass (`pnpm test:unit`)
- [ ] E2E `appModeWidgetRename` tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10507-backport-cloud-1-41-fix-App-mode-renaming-widgets-on-subgraphs-10245-32e6d73d365081059663ce4e9431f849)
by [Unito](https://www.unito.io)
2026-03-25 14:00:53 -07:00
Comfy Org PR Bot
3233ea001c [backport cloud/1.41] fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing (#10519)
Backport of #10390 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10519-backport-cloud-1-41-fix-prune-stale-proxyWidgets-referencing-nodes-removed-by-nested--32e6d73d365081ac8e6cc9f1402c2282)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-25 19:23:39 +00:00
Comfy Org PR Bot
617e2cd259 [backport cloud/1.41] 🧙 feat: wire ComfyHub publish wizard with profile gate, asset upload, and submission (#10491)
Backport of #10128 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10491-backport-cloud-1-41-feat-wire-ComfyHub-publish-wizard-with-profile-gate-asset-upl-32e6d73d3650819f8cb0d995d45fb06e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-25 10:22:20 +09:00
Comfy Org PR Bot
2e1977dc7d [backport cloud/1.41] fix: 3D asset disappears when switching to image output in app mode (#10231)
Backport of #9622 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10231-backport-cloud-1-41-fix-3D-asset-disappears-when-switching-to-image-output-in-app-mod-3276d73d3650819da889e3c1c24aa254)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 17:24:08 -07:00
Arthur R Longbottom
0fa937205c [backport cloud/1.41] fix: subgraph promoted widget input label rename (#10422)
Backport of #10195 to cloud/1.41.

Cherry-picked from the core/1.41 backport commit with the same test fix:
`renameWidget` test passes `parents` explicitly since 1.41 uses
`parents?.length` (not `node.isSubgraphNode()` fallback). Includes
negative test for subgraph node without parents.

All 14 unit tests pass locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10422-backport-cloud-1-41-fix-subgraph-promoted-widget-input-label-rename-32d6d73d36508133bdd1d37ebd2e542e)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 17:23:20 -07:00
Comfy Org PR Bot
925a5d2d63 [backport cloud/1.41] fix: restore Firebase getAdditionalUserInfo for sign-up telemetry OR logic (#10467)
Backport of #10453 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10467-backport-cloud-1-41-fix-restore-Firebase-getAdditionalUserInfo-for-sign-up-telemetry--32d6d73d365081daa1fff97d13f29266)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-24 17:12:36 -07:00
Comfy Org PR Bot
6d36f83a83 [backport cloud/1.41] feat: App mode - add lightbox to view image in drop zone (#10447)
Backport of #9888 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10447-backport-cloud-1-41-feat-App-mode-add-lightbox-to-view-image-in-drop-zone-32d6d73d3650810bad05f6863346e74b)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-24 08:40:13 -07:00
Comfy Org PR Bot
82d92a6ea0 [backport cloud/1.41] feat: App mode - enable mask editor (#10442)
Backport of #9876 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10442-backport-cloud-1-41-feat-App-mode-enable-mask-editor-32d6d73d365081828f69fb510adef7ad)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-24 08:28:45 -07:00
Comfy Org PR Bot
323d457958 [backport cloud/1.41] fix: restore workflow tabs on browser restart (#10435)
Backport of #10336 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10435-backport-cloud-1-41-fix-restore-workflow-tabs-on-browser-restart-32d6d73d36508121b463c40c1ac8d87e)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 08:38:56 +00:00
Comfy Org PR Bot
40d3b16133 [backport cloud/1.41] fix: sync advanced inputs button color with node header (#10429)
Backport of #10427 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10429-backport-cloud-1-41-fix-sync-advanced-inputs-button-color-with-node-header-32d6d73d365081eea462d909ea863ea4)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 16:09:43 +09:00
Hunter
df3fb6ca2a [backport cloud/1.41] feat: differentiate personal/team pricing table with two-stage team workspace flow (#9901) (#10401)
Backport of #9901 to cloud/1.41.

Cherry-picked merge commit cd45efa983.

**Conflict resolution:**
-
`src/platform/cloud/subscription/composables/useSubscriptionDialog.test.ts`:
modify/delete — file was new in the PR, kept PR version (git add).
-
`src/platform/cloud/subscription/composables/useSubscriptionDialog.ts`:
auto-merge dropped the `isCloud` import and early-return guard; restored
both to match the main branch version.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10401-backport-cloud-1-41-feat-differentiate-personal-team-pricing-table-with-two-stage-tea-32c6d73d365081328accfa6e0a410219)
by [Unito](https://www.unito.io)
2026-03-23 19:22:49 -04:00
Comfy Org PR Bot
b0da48e168 [backport cloud/1.41] fix: use UI flow context for sign_up vs login telemetry (#10399)
Backport of #10388 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10399-backport-cloud-1-41-fix-use-UI-flow-context-for-sign_up-vs-login-telemetry-32c6d73d3650810e89afccd7e6b85a3b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-23 10:41:59 -07:00
Comfy Org PR Bot
9f470b22c0 [backport cloud/1.41] feat: App mode - allow resizing of textarea and image previews (#10345)
Backport of #9792 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10345-backport-cloud-1-41-feat-App-mode-allow-resizing-of-textarea-and-image-previews-3296d73d36508173be60e0fc10292292)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-20 10:11:07 -07:00
Comfy Org PR Bot
80cc488e55 [backport cloud/1.41] feat: add DropZone Storybook coverage for file upload states (#10340)
Backport of #9690 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10340-backport-cloud-1-41-feat-add-DropZone-Storybook-coverage-for-file-upload-states-3296d73d365081a1b8fdc7973af2424e)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-20 09:37:53 -07:00
Comfy Org PR Bot
ac8f75c8a7 [backport cloud/1.41] refactor: Unify app builder & app widget lists (#10335)
Backport of #9829 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10335-backport-cloud-1-41-refactor-Unify-app-builder-app-widget-lists-3296d73d3650814cb17ae9b652156de3)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-20 03:50:43 -07:00
Alexander Brown
19a31f2204 [backport cloud/1.41] test: subgraph integration contracts and expanded Playwright coverage (#10328)
Backport of #10123, #9967, and #9972 to `cloud/1.41`

Includes three cherry-picks in dependency order:
1. #9972 — `fix: resolve all lint warnings` (clean)
2. #9967 — `test: harden subgraph test coverage and remove low-value
tests` (clean)
3. #10123 — `test: subgraph integration contracts and expanded
Playwright coverage` (2 conflicts, auto-resolved by rerere from #10326)

See #10326 for core/1.41 backport with detailed conflict resolution
notes.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-19 18:54:38 -07:00
Comfy Org PR Bot
72ab4d5ec9 [backport cloud/1.41] fix: configure nested subgraph definitions in dependency order (#10316)
Backport of #10314 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 15:46:29 -07:00
Comfy Org PR Bot
7883f03196 [backport cloud/1.41] fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10298)
Backport of #10289 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10298-backport-cloud-1-41-fix-_removeDuplicateLinks-incorrectly-removes-valid-link-when-slo-3286d73d365081c88411efa19a1cce41)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-19 15:54:55 +09:00
Comfy Org PR Bot
5a9c7980d6 [backport cloud/1.41] fix: prevent nested SubgraphNode input slots from doubling on reload (#10280)
Backport of #10187 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10280-backport-cloud-1-41-fix-prevent-nested-SubgraphNode-input-slots-from-doubling-on-relo-3276d73d36508130855ec81c1e96a50a)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-19 09:24:39 +09:00
Comfy Org PR Bot
2b25755e4f [backport cloud/1.41] fix: resync slot layouts when switching between app mode and graph mode (#10281)
Backport of #10273 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10281-backport-cloud-1-41-fix-resync-slot-layouts-when-switching-between-app-mode-and-graph-3276d73d365081b5bd7be61796c397a2)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 17:02:22 -07:00
Comfy Org PR Bot
3b0cbd4035 [backport cloud/1.41] feat: App mode - update keybindings (#10249)
Backport of #9794 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10249-backport-cloud-1-41-feat-App-mode-update-keybindings-3276d73d3650814d9ef6ce9431679045)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 14:34:37 -07:00
Comfy Org PR Bot
c954ee1a77 [backport cloud/1.41] fix: App mode - handle socket/response race when tracking jobs (#10266)
Backport of #10244 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10266-backport-cloud-1-41-fix-App-mode-handle-socket-response-race-when-tracking-jobs-3276d73d36508180b559c6dea9b0457c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 14:34:30 -07:00
Comfy Org PR Bot
fc15e6a329 [backport cloud/1.41] fix: resync vue node layout store after legacy normalization (#10262)
Backport of #10256 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10262-backport-cloud-1-41-fix-resync-vue-node-layout-store-after-legacy-normalization-3276d73d365081da8d9eed872ec34f2f)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:30:04 -07:00
Alexander Brown
5f99f7bdba fix: resolve cloud/1.41 unit test regressions (#10269)
## Summary

Fixes three cloud/1.41 test regressions by restoring the intended legacy
topbar mutation scheduling behavior, re-enabling multi-output test
mocking in assetsStore tests, and backporting execution ID locator
caching in executionStore.

## Changes

- **What**: Reintroduced RAF-coalesced legacy topbar content checks in
`TopMenuSection`, restored `mockOutputOverrides` handling in
`assetsStore.test.ts`, and added cached execution-id-to-locator
resolution in `executionStore` with cache resets at execution
boundaries.

## Review Focus

Confirm the cache lifecycle boundaries in `executionStore`
(`handleExecutionStart` and `resetExecutionState`) and that the
`TopMenuSection` mutation observer now matches the coalescing contract
expected by unit tests.

Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 13:20:55 -07:00
Comfy Org PR Bot
6bb46d688f [backport cloud/1.41] fix: track nodePreviewImages in usePromotedPreviews (#10198)
Backport of #10165 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10198-backport-cloud-1-41-fix-track-nodePreviewImages-in-usePromotedPreviews-3266d73d365081e3b176ffad3b061dd8)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 14:39:13 -07:00
Comfy Org PR Bot
2fb463a200 [backport cloud/1.41] fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10216)
Backport of #10203 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10216-backport-cloud-1-41-fix-replace-stale-request-guard-with-single-flight-coalescing-in--3266d73d365081329667e34fac063cbe)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 14:37:34 -07:00
Comfy Org PR Bot
8c54bfd9a9 [backport cloud/1.41] fix: enable 3D thumbnail support for cloud environments (#10206)
Backport of #10121 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10206-backport-cloud-1-41-fix-enable-3D-thumbnail-support-for-cloud-environments-3266d73d365081bb822efce5409352af)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 13:52:08 -07:00
Comfy Org PR Bot
72547cdc9d [backport cloud/1.41] feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10180)
Backport of #10111 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10180-backport-cloud-1-41-feat-resolveVirtualOutput-for-cross-subgraph-virtual-nodes-eg-S-3266d73d365081fa9f89d8622b4aac9c)
by [Unito](https://www.unito.io)

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-17 07:19:08 -07:00
Christian Byrne
a73d31e538 [backport cloud/1.41] feat: add linear interpolation type to CURVE widget (#10166)
Backport of #10118 to cloud/1.41.

Cherry-pick of 46d8567f10.

## Original PR
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10118

## Summary
Change the CURVE widget value from CurvePoint[] to CurveData ({ points,
interpolation }) to support multiple interpolation types. Add a Select
dropdown in the widget UI for switching between Smooth (monotone cubic)
and Linear interpolation, with the SVG preview updating accordingly.

## Conflict Resolution
- CurveEditor.vue, WidgetCurve.vue, curveUtils.ts: Took main version
which merges both #9896 (disabled/upstream) and #10118 (interpolation)
features
- locales/en/main.json: Auto-merged cleanly
- package.json: Kept target branch version

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10166-backport-cloud-1-41-feat-add-linear-interpolation-type-to-CURVE-widget-3266d73d365081e8ba22c73c1590eb09)
by [Unito](https://www.unito.io)
2026-03-17 03:04:25 -07:00
Christian Byrne
855c4d74b6 [backport cloud/1.41] feat: improve essentials tab blueprint support and display names (#10161)
Backport of #10113 to `cloud/1.41`

Cherry-picked merge commit d6c1dd2e59.
Resolved import conflict in `NodeLibrarySidebarTabV2.vue` — kept
cloud/1.41's `SearchBox` component import, added new
`resolveBlueprintSuffix` import from the PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10161-backport-cloud-1-41-feat-improve-essentials-tab-blueprint-support-and-display-names-3266d73d3650818ba12af2ac5631b4a6)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-17 01:49:27 -07:00
Comfy Org PR Bot
ecdca891c0 [backport cloud/1.41] fix: prune orphaned SubgraphNode inputs after configure (#10148)
Backport of #10020 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10148-backport-cloud-1-41-fix-prune-orphaned-SubgraphNode-inputs-after-configure-3266d73d36508122b6ffd63a32397750)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 01:25:00 -07:00
Comfy Org PR Bot
f151941eec [backport cloud/1.41] Feat/3d thumbnail inline rendering (#10044)
Backport of #9471 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10044-backport-cloud-1-41-Feat-3d-thumbnail-inline-rendering-3256d73d3650816fa383cc675152d920)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:46:42 -07:00
Christian Byrne
591c43f2f5 [cloud/1.41] fix: revert incorrectly backported code from #9908 and #10024 (#10101)
Fix typecheck failures caused by backport conflict resolutions that
brought in code depending on features not present on 1.41 branch.
2026-03-16 06:28:17 -07:00
Christian Byrne
93b7b7305a [backport cloud/1.41] fix: inline splash CSS to prevent SPA fallback breakage on cloud environments (#9849) (#10092)
Backport of #9849 to cloud/1.41

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:17:43 -07:00
Christian Byrne
0024cc6ff7 [backport cloud/1.41] fix: allow URL input for free tier users, gate on import button (#10024) (#10091)
Backport of #10024 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 06:16:59 -07:00
Christian Byrne
21f23b95b8 [backport cloud/1.41] fix: address PR review feedback for upstream value composable (#9908) (#10090)
Backport of #9908 to cloud/1.41

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 06:16:28 -07:00
Christian Byrne
76e5620109 [backport cloud/1.41] fix: default frontend preview variant to cpu (#9718) (#10089)
Backport of #9718 to cloud/1.41

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-16 06:15:52 -07:00
Christian Byrne
2cba8ff108 [backport cloud/1.41] fix: make zPreviewOutput accept text-only job outputs (#9724) (#10088)
Backport of #9724 to cloud/1.41

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
2026-03-16 06:15:20 -07:00
Christian Byrne
2cb1f580b1 [backport cloud/1.41] fix: hide empty actionbar container and relocate error border to floating actionbar (#9657) (#10087)
Backport of #9657 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 06:13:53 -07:00
Christian Byrne
63a2a36b3f [backport cloud/1.41] fix: center ComfyUI logo in sidebar menu button with chevron (#9722) (#10086)
Backport of #9722 to cloud/1.41

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 06:13:12 -07:00
Comfy Org PR Bot
6f2eb0ebea [backport cloud/1.41] fix: prevent WebGLRenderer leak in app mode 3D preview (#10084)
Backport of #9766 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:09:25 -07:00
Comfy Org PR Bot
65e9e79313 [backport cloud/1.41] fix: cloud login page stuck on splash loader for unauthenticated users (#10083)
Backport of #9725 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:09:18 -07:00
Comfy Org PR Bot
b4022e1de6 [backport cloud/1.41] fix: cloud subscribe redirect hangs waiting for billing init (#10082)
Backport of #9965 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:09:09 -07:00
Comfy Org PR Bot
ff2e7c47ad [backport cloud/1.41] fix(maskeditor): make brush size slider logarithmic (#8097) (#10071)
Backport of #9534 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10071-backport-cloud-1-41-fix-maskeditor-make-brush-size-slider-logarithmic-8097-3256d73d3650813f83c6f0e2d81a185d)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2026-03-16 06:09:01 -07:00
Comfy Org PR Bot
ec5527123c [backport cloud/1.41] fix: rename docked queue panel setting (#10070)
Backport of #9620 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10070-backport-cloud-1-41-fix-rename-docked-queue-panel-setting-3256d73d3650819b96c0ec6afe0a6266)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-03-16 06:08:54 -07:00
Comfy Org PR Bot
a8fc9d67a7 [backport cloud/1.41] fix: update WidgetLayoutField border styling (#10069)
Backport of #9456 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10069-backport-cloud-1-41-fix-update-WidgetLayoutField-border-styling-3256d73d3650817f9f12cffc7ed4dcf9)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:08:46 -07:00
Comfy Org PR Bot
f8191837ec [backport cloud/1.41] fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#10068)
Backport of #9332 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10068-backport-cloud-1-41-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nod-3256d73d365081f79234d019c2a238b2)
by [Unito](https://www.unito.io)

Co-authored-by: woctordho <woctordho@outlook.com>
2026-03-16 06:08:38 -07:00
Comfy Org PR Bot
a0f0b27eee [backport cloud/1.41] fix: show most recent image first in asset sidebar batch view (#10067)
Backport of #9467 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10067-backport-cloud-1-41-fix-show-most-recent-image-first-in-asset-sidebar-batch-view-3256d73d365081c595ece0f7e9370619)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 06:08:29 -07:00
Comfy Org PR Bot
f9efc35abd [backport cloud/1.41] fix: improve canvas menu keyboard navigation and ARIA accessibility (#10066)
Backport of #9526 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10066-backport-cloud-1-41-fix-improve-canvas-menu-keyboard-navigation-and-ARIA-accessibilit-3256d73d365081259b03ec81725efd75)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 06:08:21 -07:00
Comfy Org PR Bot
464ec47c5e [backport cloud/1.41] fix: show correct empty state on Missing tab instead of misleading registry error (#10065)
Backport of #9640 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10065-backport-cloud-1-41-fix-show-correct-empty-state-on-Missing-tab-instead-of-misleading-3256d73d3650819aae75f6244541b266)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:08:13 -07:00
Comfy Org PR Bot
64fcbeee29 [backport cloud/1.41] fix: virtual scroll pagination not working in media asset list view (#10064)
Backport of #9646 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10064-backport-cloud-1-41-fix-virtual-scroll-pagination-not-working-in-media-asset-list-vie-3256d73d365081c2b5e2d3f2a6bafb09)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:08:05 -07:00
Comfy Org PR Bot
89aae59a4f [backport cloud/1.41] fix: add text color and increase size for nav badge count (#10063)
Backport of #9713 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10063-backport-cloud-1-41-fix-add-text-color-and-increase-size-for-nav-badge-count-3256d73d3650812d9777d5cbe66781a3)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:07:57 -07:00
Comfy Org PR Bot
83d1aebc21 [backport cloud/1.41] fix: prevent HoneyToast from collapsing to minimum width in collapsed state (#10062)
Backport of #9701 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10062-backport-cloud-1-41-fix-prevent-HoneyToast-from-collapsing-to-minimum-width-in-collap-3256d73d365081fba15bd2475ac88089)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 06:07:49 -07:00
Comfy Org PR Bot
dc64fc2fe1 [backport cloud/1.41] fix: use order-independent tag matching in asset browser categories (#10061)
Backport of #9843 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10061-backport-cloud-1-41-fix-use-order-independent-tag-matching-in-asset-browser-categorie-3256d73d36508153be6cc940ec943795)
by [Unito](https://www.unito.io)

Co-authored-by: Luke Mino-Altherr <luke@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 06:07:41 -07:00
Christian Byrne
7918a79d61 [backport cloud/1.41] fix: return undefined for muted node output resolution (#9302) (#10055)
Backport of #9302 to cloud/1.41
2026-03-16 05:58:05 -07:00
Christian Byrne
4ee2bbd65f [backport cloud/1.41] fix: cap nodeProgressStatesByJob to prevent unbounded memory growth (#9249) (#10054)
Backport of #9249 to cloud/1.41

Co-authored-by: John Haugeland <stonecypher@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 05:57:44 -07:00
Christian Byrne
196a171893 [backport cloud/1.41] fix: subgraph output slot labels not updating in v2 renderer (#9266) (#10053)
Backport of #9266 to cloud/1.41

Co-authored-by: Arthur R Longbottom <art.longbottom.jr@gmail.com>
2026-03-16 05:57:08 -07:00
Christian Byrne
a3c9d3e84a [backport cloud/1.41] fix: add isGraphReady guard to prevent premature graph access error logs (#9672) (#10052)
Backport of #9672 to cloud/1.41

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-16 05:56:29 -07:00
Christian Byrne
a6c99423c8 [backport cloud/1.41] fix: simplify ensureCorrectLayoutScale and fix link sync during Vue node drag (#9680) (#10051)
Backport of #9680 to cloud/1.41

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-16 05:56:04 -07:00
Christian Byrne
a1634cedea [backport cloud/1.41] fix: clear stale progress bar on SubgraphNode after navigation (#9865) (#10050)
Backport of #9865 to cloud/1.41
2026-03-16 05:55:32 -07:00
Comfy Org PR Bot
7fe1c1e8c5 [backport cloud/1.41] fix: prevent middle-click paste duplicating workflow on Linux (#10040)
Backport of #8259 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10040-backport-cloud-1-41-fix-prevent-middle-click-paste-duplicating-workflow-on-Linux-3256d73d36508136884fd82505fb37d9)
by [Unito](https://www.unito.io)

Co-authored-by: Gregorius Bima Kharisma Wicaksana <51526537+bimakw@users.noreply.github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-16 05:53:43 -07:00
Comfy Org PR Bot
876592fe9b [backport cloud/1.41] fix: detect and remove duplicate links in subgraph unpacking (#10039)
Backport of #9120 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10039-backport-cloud-1-41-fix-detect-and-remove-duplicate-links-in-subgraph-unpacking-3256d73d365081aabd35e07f65a537b5)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-16 05:53:35 -07:00
Comfy Org PR Bot
da1d915898 [backport cloud/1.41] fix: detect missing nodes when registry API fails to resolve packs (#10038)
Backport of #9697 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10038-backport-cloud-1-41-fix-detect-missing-nodes-when-registry-API-fails-to-resolve-packs-3256d73d36508143a512c580163a4f4a)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 05:53:27 -07:00
Comfy Org PR Bot
2d93372e67 [backport cloud/1.41] fix: load API format workflows with missing node types (#10037)
Backport of #9694 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10037-backport-cloud-1-41-fix-load-API-format-workflows-with-missing-node-types-3256d73d3650811782fad8b489bcf253)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-16 05:53:19 -07:00
Comfy Org PR Bot
67432c5db5 [backport cloud/1.41] fix: prevent white flash when opening mask editor (#10036)
Backport of #9860 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10036-backport-cloud-1-41-fix-prevent-white-flash-when-opening-mask-editor-3256d73d36508141a578eaa4b14645d3)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 05:53:11 -07:00
Comfy Org PR Bot
9c93f11abe [backport cloud/1.41] fix: prevent animated preview duplication on Vue↔Litegraph switch (#10031)
Backport of #9938 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10031-backport-cloud-1-41-fix-prevent-animated-preview-duplication-on-Vue-Litegraph-switch-3256d73d3650812882a3cd7ef0f121c1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-16 05:48:32 -07:00
Comfy Org PR Bot
765ae28c53 [backport cloud/1.41] [bugfix] Align advanced footer design with subgraph footer layout (#10012)
Backport of #9879 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10012-backport-cloud-1-41-bugfix-Align-advanced-footer-design-with-subgraph-footer-layout-3256d73d3650810e8f18ca77cfa409e0)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-16 02:12:15 -07:00
Comfy Org PR Bot
ab5e360391 [backport cloud/1.41] fix: tree explorer nodes not filling parent container width (#9982)
Backport of #9964 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9982-backport-cloud-1-41-fix-tree-explorer-nodes-not-filling-parent-container-width-3246d73d3650818cabdff63e1be3fea0)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 02:11:37 -07:00
Comfy Org PR Bot
3d4d324019 [backport cloud/1.41] fix: add reve and elevenlabs to icon safelist (#9992)
Backport of #9990 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9992-backport-cloud-1-41-fix-add-reve-and-elevenlabs-to-icon-safelist-3256d73d3650814ab426c6c4d5e7eeaf)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 02:11:30 -07:00
Comfy Org PR Bot
1829fe32da [backport cloud/1.41] fix: mask editor save shows blank image in Load Image node (#10019)
Backport of #9984 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10019-backport-cloud-1-41-fix-mask-editor-save-shows-blank-image-in-Load-Image-node-3256d73d36508178ae70e4889fbfd3f7)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:14:22 +09:00
Comfy Org PR Bot
0de694ce8d [backport cloud/1.41] fix: LGraphGroup paste position (#9971)
Backport of #9962 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-15 18:54:51 +00:00
Comfy Org PR Bot
03d192e605 [backport cloud/1.41] fix: prevent live preview dimension flicker between frames (#9956)
Backport of #9937 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9956-backport-cloud-1-41-fix-prevent-live-preview-dimension-flicker-between-frames-3246d73d365081f19349cf0dba7d32c2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-15 03:01:38 -07:00
Alexander Brown
8ca6a1799b [backport cloud/1.41] fix: stabilize subgraph promoted widget identity and rendering (#9929)
Backport of #9896 to `cloud/1.41`.

Conflict resolution notes:
- Resolved expected conflicts in `useGraphNodeManager.test.ts`,
`SubgraphNode.ts`, and `NodeWidgets.vue`.
- Kept core promoted-widget identity/rendering fixes from #9896.
- Dropped missing-model/source-execution specific test/runtime additions
not available on `cloud/1.41` APIs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9929-backport-cloud-1-41-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3236d73d36508189b968de3cdc6c2c48)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 15:43:29 -07:00
Comfy Org PR Bot
02d6ecf897 [backport cloud/1.41] fix: clear stale widget slotMetadata on link disconnect (#9920)
Backport of #9885 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9920-backport-cloud-1-41-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3236d73d36508138a4a1c72ba9953e4b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-14 12:18:08 -07:00
Comfy Org PR Bot
d9db335f58 [backport cloud/1.41] fix: restore native copy/paste events for image paste support (#9917)
Backport of #9914 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9917-backport-cloud-1-41-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081eab749f1e82ab599b2)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-03-14 01:21:59 -07:00
Comfy Org PR Bot
d1827eecf3 [backport cloud/1.41] fix: hide template selector after shared workflow accept (#9915)
Backport of #9913 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 08:12:19 +00:00
Comfy Org PR Bot
73435ee2d9 [backport cloud/1.41] feat: add model-to-node mappings for CogVideo, inpaint, and LayerDiffuse (#9907)
Backport of #9890 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9907-backport-cloud-1-41-feat-add-model-to-node-mappings-for-CogVideo-inpaint-and-LayerDi-3236d73d365081c0acdfcf4302f95d7f)
by [Unito](https://www.unito.io)

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:49:34 -07:00
Comfy Org PR Bot
fa8716572d [backport cloud/1.41] feat(telemetry): add view_mode and is_app_mode to run_button_click event (#9889)
Backport of #9881 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9889-backport-cloud-1-41-feat-telemetry-add-view_mode-and-is_app_mode-to-run_button_click--3226d73d365081b587cbc91201a0edc1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-13 11:40:25 -07:00
Comfy Org PR Bot
951a26775c [backport cloud/1.41] Add support for values factory function in widget select combo (#9883)
Backport of #8775 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9883-backport-cloud-1-41-Add-support-for-values-factory-function-in-widget-select-combo-3226d73d3650817186b8c8827b123140)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-13 10:36:33 -07:00
Comfy Org PR Bot
80155ee06c [backport cloud/1.41] fix: switching tabs in app mode clearing outputs (#9873)
Backport of #9745 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9873-backport-cloud-1-41-fix-switching-tabs-in-app-mode-clearing-outputs-3226d73d365081ed8dadffcf823d8f62)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-13 10:36:25 -07:00
Comfy Org PR Bot
9764c80116 [backport cloud/1.41] feat: show ComfyUI context menu on textarea widget right-click (#9871)
Backport of #9840 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9871-backport-cloud-1-41-feat-show-ComfyUI-context-menu-on-textarea-widget-right-click-3226d73d3650819a9c81caaccbc2f89a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-13 09:02:30 -07:00
Comfy Org PR Bot
d13061b943 [backport cloud/1.41] fix: advanced widgets always visible regardless of setting (#9867)
Backport of #9857 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9867-backport-cloud-1-41-fix-advanced-widgets-always-visible-regardless-of-setting-3226d73d365081b0a02fe06dcd192418)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:42:56 -07:00
Robin Huang
c0ea8f1e31 [backport cloud/1.41] feat: add server-side PostHog config overrides (#9758) (#9855) 2026-03-12 23:29:53 -07:00
Comfy Org PR Bot
2c8ad1380f [backport cloud/1.41] feat: bake frontend commit hash into build (#9848)
Backport of #9832 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9848-backport-cloud-1-41-feat-bake-frontend-commit-hash-into-build-3226d73d3650817aa852f91f44a13091)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-13 01:14:02 +00:00
Comfy Org PR Bot
af8d502d81 [backport cloud/1.41] fix: update workspace creation modal phrasing for credit pool clarity (#9845)
Backport of #9811 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9845-backport-cloud-1-41-fix-update-workspace-creation-modal-phrasing-for-credit-pool-clari-3226d73d365081a1b79ffa3e7b11ab95)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 17:21:51 -07:00
Comfy Org PR Bot
c48953839f [backport cloud/1.41] fix: omit job_asset_name_filters when all job outputs selected (#9834)
Backport of #9684 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9834-backport-cloud-1-41-fix-omit-job_asset_name_filters-when-all-job-outputs-selected-3216d73d36508187b5b1c04d2fe1b6c0)
by [Unito](https://www.unito.io)

Co-authored-by: Simon Pinfold <synap5e@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-12 17:13:37 -07:00
Comfy Org PR Bot
86e67d163d [backport cloud/1.41] feat: set subscription tier as PostHog user property (#9836)
Backport of #9764 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9836-backport-cloud-1-41-feat-set-subscription-tier-as-PostHog-user-property-3216d73d365081989911cc0c18906556)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 15:07:55 -07:00
Comfy Org PR Bot
7ad2f195e4 [backport cloud/1.41] fix: dropdown widget fetching output files (#9831)
Backport of #6734 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9831-backport-cloud-1-41-fix-dropdown-widget-fetching-output-files-3216d73d3650810d83c7f3dd325c0735)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-12 13:54:05 -07:00
Comfy Org PR Bot
eedf03a709 [backport cloud/1.41] fix: preserve input asset previews across execution updates (#9816)
Backport of #9123 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9816-backport-cloud-1-41-fix-preserve-input-asset-previews-across-execution-updates-3216d73d365081249b0ef983d3d283f6)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-12 09:20:58 -07:00
Comfy Org PR Bot
4b91e35d1d [backport cloud/1.41] fix: skip node metadata paste when media node is selected (#9814)
Backport of #9773 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9814-backport-cloud-1-41-fix-skip-node-metadata-paste-when-media-node-is-selected-3216d73d365081b18d4ac8878607387d)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 09:00:50 -07:00
Comfy Org PR Bot
32f757b54a [backport cloud/1.41] fix: restore correct workflow on page reload (#9800)
Backport of #9318 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9800-backport-cloud-1-41-fix-restore-correct-workflow-on-page-reload-3216d73d36508130b880d55524b987de)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:53:07 -07:00
Comfy Org PR Bot
37b9593b9a [backport cloud/1.41] feat: add copy button to System Info panel (#9721)
Backport of #9719 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9721-backport-cloud-1-41-feat-add-copy-button-to-System-Info-panel-31f6d73d365081209262f46e34a62570)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:51:41 -07:00
Comfy Org PR Bot
e3baf6df0b [backport cloud/1.41] fix: call checkState after image input changes for proper undo tracking (#9810)
Backport of #9623 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9810-backport-cloud-1-41-fix-call-checkState-after-image-input-changes-for-proper-undo-trac-3216d73d365081669860d7a44ebea499)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:39:44 -07:00
Comfy Org PR Bot
34953a7f98 [backport cloud/1.41] fix: app mode widgets disappear after hard refresh (#9772)
Backport of #9621 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9772-backport-cloud-1-41-fix-app-mode-widgets-disappear-after-hard-refresh-3216d73d3650813a9437f1ae6e7e04d7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-03-12 08:04:50 -07:00
Luke Mino-Altherr
b1bfe5fb46 [backport cloud/1.41] fix: make zPreviewOutput accept text-only job outputs (#9765)
Backport of #9724 to `cloud/1.41`

Cherry-pick applied cleanly with no conflicts.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9765-backport-cloud-1-41-fix-make-zPreviewOutput-accept-text-only-job-outputs-3216d73d365081f4880dca7bcf1d4bc9)
by [Unito](https://www.unito.io)
2026-03-11 19:11:53 -07:00
Comfy Org PR Bot
a5e5e4813a [backport cloud/1.41] fix: restore widget.inputEl backward compatibility for custom nodes (#9761)
Backport of #9759 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9761-backport-cloud-1-41-fix-restore-widget-inputEl-backward-compatibility-for-custom-nodes-3216d73d365081aa82ecda6871d0c351)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:55:16 +00:00
Comfy Org PR Bot
1d05f08edd [backport cloud/1.41] feat: add remote config support for PostHog debug mode (#9756)
Backport of #9755 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:29:13 -07:00
Robin Huang
936291eb85 [backport cloud/1.41] feat: add telemetry for workflow save and default view (#9753)
Backport of #9734 to cloud/1.41. Adds telemetry events for workflow save
and default view in App Builder.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9753-backport-cloud-1-41-feat-add-telemetry-for-workflow-save-and-default-view-3206d73d3650818b833adb3062c0ae62)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:59:18 -07:00
Comfy Org PR Bot
fd352a4a8f [backport cloud/1.41] fix: update PostHog api_host fallback domain (#9736)
Backport of #9733 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9736-backport-cloud-1-41-fix-update-PostHog-api_host-fallback-domain-3206d73d36508178a7e1ef3ccf8dbb9a)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:49:50 -07:00
Comfy Org PR Bot
99db641662 [backport cloud/1.41] feat: track app mode entry and shared workflow loading (#9723)
Backport of #9720 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9723-backport-cloud-1-41-feat-track-app-mode-entry-and-shared-workflow-loading-31f6d73d365081f1b37dcfba85d779ec)
by [Unito](https://www.unito.io)

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:12:15 +00:00
Comfy Org PR Bot
4e933d504c [backport cloud/1.41] Mobile input tweaks (#9711)
Backport of #9686 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9711-backport-cloud-1-41-Mobile-input-tweaks-31f6d73d365081eaa746f3aca28bab94)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 23:35:07 -07:00
Comfy Org PR Bot
e3dc4a96e8 [backport cloud/1.41] Redirect authenticated users from signup page to cloud (#9693)
Backport of #9691 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-09 23:11:40 -07:00
Robin Huang
ce40e5ef85 [backport cloud/1.41] feat: show user avatar for personal workspace (#9705)
Backport of #9687 to cloud/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9705-backport-cloud-1-41-feat-show-user-avatar-for-personal-workspace-31f6d73d36508193b3adfe0d9e404695)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 22:31:34 -07:00
Robin Huang
7302f550bd [backport cloud/1.41] feat: show App/Node Graph type indicator on template cards (#9706)
Backport of #9695 to cloud/1.41.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9706-backport-cloud-1-41-feat-show-App-Node-Graph-type-indicator-on-template-cards-31f6d73d36508177a941cd23219a7a24)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-09 22:31:17 -07:00
Comfy Org PR Bot
37be2eca91 [backport cloud/1.41] Use preview downscaling in fewer places (#9683)
Backport of #9678 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9683-backport-cloud-1-41-Use-preview-downscaling-in-fewer-places-31e6d73d3650816aac69c4d1f01fa35a)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 16:16:32 -07:00
Comfy Org PR Bot
ca66943826 [backport cloud/1.41] Restore hiding of linked inputs in app mode (#9677)
Backport of #9671 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9677-backport-cloud-1-41-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d36508192af4ac6a5178d97c4)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:42:42 -07:00
Comfy Org PR Bot
64e1983231 [backport cloud/1.41] Always use interior nodeId for app mode (#9675)
Backport of #9669 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9675-backport-cloud-1-41-Always-use-interior-nodeId-for-app-mode-31e6d73d365081a8a743c5f23a30a620)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 13:25:20 -07:00
Comfy Org PR Bot
2c8974d250 [backport cloud/1.41] fix: show load widget inputs in media dropdown (#9673)
Backport of #9670 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-09 19:58:01 +00:00
Comfy Org PR Bot
a77b452186 [backport cloud/1.41] style: Update share icon to be a send icon instead (#9668)
Backport of #9667 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9668-backport-cloud-1-41-style-Update-share-icon-to-be-a-send-icon-instead-31e6d73d365081edad8ef5df7d3ce8be)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 10:14:13 -07:00
Comfy Org PR Bot
9b3d80955b [backport cloud/1.41] fix: Add a tooltip to account for assets with really long names. (#9666)
Backport of #9665 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9666-backport-cloud-1-41-fix-Add-a-tooltip-to-account-for-assets-with-really-long-names-31e6d73d36508148ac30f80fc7e94005)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-09 10:05:48 -07:00
Comfy Org PR Bot
c1cfe6ac73 [backport cloud/1.41] Even further app fixes (#9664)
Backport of #9617 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9664-backport-cloud-1-41-Even-further-app-fixes-31e6d73d36508110b037e04f2de62655)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-09 09:45:08 -07:00
Comfy Org PR Bot
653c278f91 [backport cloud/1.41] feat: display original asset names instead of hashes in assets panel (#9638)
Backport of #9626 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9638-backport-cloud-1-41-feat-display-original-asset-names-instead-of-hashes-in-assets-pane-31e6d73d365081ac9364f28eafbdf6a6)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-09 09:41:29 -07:00
Comfy Org PR Bot
165856e1a0 [backport cloud/1.41] fix: prevent showing outputs in app mode when no output nodes configured (#9633)
Backport of #9625 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9633-backport-cloud-1-41-fix-prevent-showing-outputs-in-app-mode-when-no-output-nodes-confi-31e6d73d365081c993c2cf13bcf2ed1e)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-08 17:44:34 -07:00
Comfy Org PR Bot
f27da404f0 [backport cloud/1.41] fix: restore backend outputs_count for asset sidebar multi-output badge (#9628)
Backport of #9627 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9628-backport-cloud-1-41-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-b-31d6d73d365081d0ba1ccaa88dcd8ce5)
by [Unito](https://www.unito.io)

Co-authored-by: Hunter <huntcsg@users.noreply.github.com>
2026-03-08 17:35:57 -07:00
Comfy Org PR Bot
0f4ad8098d [backport cloud/1.41] fix: use previewable output count for asset sidebar badge (#9560)
Backport of #9535 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9560-backport-cloud-1-41-fix-use-previewable-output-count-for-asset-sidebar-badge-31d6d73d36508186926afc568b6f55c6)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 19:02:37 -08:00
Comfy Org PR Bot
f4828c4a25 [backport cloud/1.41] fix: remove timeouts from error toasts so they persist until dismissed (#9553)
Backport of #9543 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9553-backport-cloud-1-41-fix-remove-timeouts-from-error-toasts-so-they-persist-until-dismis-31d6d73d365081d39183dcd7c83b3aa0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 18:06:01 -08:00
Comfy Org PR Bot
af17ab440d [backport cloud/1.41] feat/fix: App mode further updates (#9547)
Backport of #9545 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9547-backport-cloud-1-41-feat-fix-App-mode-further-updates-31d6d73d36508144845dc78cdb14c85c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 17:06:33 -08:00
Comfy Org PR Bot
444816053f [backport cloud/1.41] fix: prevent non-widget inputs on nested subgraphs from appearing as button widgets (#9544)
Backport of #9542 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-07 23:06:54 +00:00
Comfy Org PR Bot
0483c6819e [backport cloud/1.41] fix: remove workspace switching confirmation dialog (#9540)
Backport of #9250 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9540-backport-cloud-1-41-fix-remove-workspace-switching-confirmation-dialog-31c6d73d365081cb87dbfbe65de23cd9)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-07 14:55:45 -08:00
Comfy Org PR Bot
df9b359cc3 [backport cloud/1.41] Yet further app fixes (#9541)
Backport of #9523 to `cloud/1.41`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-07 22:29:02 +00:00
Comfy Org PR Bot
1c9edeb604 [backport cloud/1.41] fix: Prevent corruption of workflow data due to checkState during graph loading (#9537)
Backport of #9531 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9537-backport-cloud-1-41-fix-Prevent-corruption-of-workflow-data-due-to-checkState-during-g-31c6d73d3650810a9963e85b18084e31)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 14:01:55 -08:00
Comfy Org PR Bot
859070f3fe [backport cloud/1.41] feat: Integrated tab UI updates (#9538)
Backport of #8516 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9538-backport-cloud-1-41-feat-Integrated-tab-UI-updates-31c6d73d36508199a52cf9312ce5ad6c)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 14:01:43 -08:00
Comfy Org PR Bot
65ac0e586d [backport cloud/1.41] feat/fix: App mode QA fixes (#9536)
Backport of #9530 to `cloud/1.41`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9536-backport-cloud-1-41-feat-fix-App-mode-QA-fixes-31c6d73d365081d69edfe52a6e1441b6)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-07 14:01:17 -08:00
Benjamin Lu
1ada6dbfc6 fix: align run controls with queue modal design (#9134)
## Summary
- move queue batch controls to the left of the run button
- align run control styling to the Figma queue modal spec using PrimeVue
PT/Tailwind (secondary background on batch + dropdown, primary run
button)
- normalize control heights to match actionbar buttons and tighten
dropdown hit area
- update run typography/spacing and replace all three chevrons (dropdown
+ batch up/down) with the requested SVG

Design:
https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3845-23904&m=dev

<img width="303" height="122" alt="image"
src="https://github.com/user-attachments/assets/4ed80ee7-3ceb-4512-96ce-f55ec6da835e"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9134-fix-align-run-controls-with-queue-modal-design-3106d73d36508160afcedbcfe4b98291)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-06 20:10:59 -08:00
Hunter
f02adf84eb feat: dispatch cloud build when preview label is added to PR (#9518)
## Summary

Dispatch a `frontend-asset-build` event to the cloud repo when the
`preview` label is added to a PR, so cloud can build preview assets.

## Changes

- **What**: Extended `cloud-dispatch-build.yaml` to trigger on
`pull_request` `labeled` events filtered to the `preview` label. The
payload sends the PR head SHA and branch.

## Review Focus

- The `pull_request` trigger gives a read-only `GITHUB_TOKEN`, but the
dispatch step uses `CLOUD_DISPATCH_TOKEN` so this is fine.
- Fork PRs are blocked by the existing `github.repository` guard.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9518-feat-dispatch-cloud-build-when-preview-label-is-added-to-PR-31c6d73d365081a8aab6f585960977f6)
by [Unito](https://www.unito.io)
2026-03-07 03:57:08 +00:00
pythongosssss
1058b7d12d feat/fix: App mode QA feedback 2 (#9511)
## Summary

Additional fixes and updates based on testing

## Changes

- **What**: 
- add warning to welcome screen & when sharing an app that has had all
outputs removed
- fix target workflow when changing mode via tab right click menu
- change build app text to be conditional "edit" vs "build" depending on
if an app is already defined
- update empty apps sidebar tab button text to make it clearer
- remove templates button from app mode (we will reintroduce this once
we have app templates)
- add "exit to graph" after applying default mode of node graph
- update cancel button to remove item from queue if it hasn't started
yet
- improve scoping of jobs/outputs to the current workflow [not perfect
but should be much improved]
- close sidebar tabs on entering app mode
- change tooltip to be under the workflow menu rather than covering the
button

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9511-feat-fix-App-mode-QA-feedback-2-31b6d73d365081d59bbbc13111100d46)
by [Unito](https://www.unito.io)
2026-03-06 18:57:03 -08:00
jaeone94
8bfd93963f [style] Update error/subgraph node footer design with layered overlay approach (#9360)
## Summary

Refactors the error and subgraph node footer UI by extracting a
dedicated `NodeFooter` component and replacing the CSS `outline`
approach with a layered border overlay for selection/executing state
indicators.

## Changes

- **What**: Extracted `NodeFooter.vue` from `LGraphNode.vue` to
encapsulate the footer tab logic (subgraph enter, error, advanced
inputs). Replaced CSS `outline` with an absolutely-positioned border
overlay div for selection and executing state. Added a separate root
border overlay div for the node body border. Removed unused
`isTransparent` function from `colorUtil.ts`.
- **Dependencies**: None

## Review Focus

- The layered overlay approach (`absolute -inset-[3px] border-3`) for
selection/executing outlines vs the previous `outline-3` approach —
ensures the outline renders outside the node bounds correctly including
the footer area
- `NodeFooter` handles 4 cases: subgraph+error (dual tabs), error only,
subgraph only, advanced inputs — verify edge cases render correctly
- Resize handle bottom offset adjustments for nodes with footers
(`hasFooter`)

## Screenshots
<img width="1142" height="603" alt="image"
src="https://github.com/user-attachments/assets/e0d401f0-8516-4f5f-ab77-48a79530f4bd"
/>
<img width="1175" height="577" alt="image"
src="https://github.com/user-attachments/assets/bcf08fff-728a-491c-add9-5b96d2f3bfce"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9360-style-Update-error-subgraph-node-footer-design-with-layered-overlay-approach-3186d73d365081b2ac31f166f4d1944a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-06 17:51:08 -08:00
Benjamin Lu
3366079f59 test: disable missing model warnings in browser tests (#9513)
Disable missing model warnings in browser tests by default.

Browser tests run without model files on disk, so workflows that embed
model metadata can render differently in CI than the test actually
intends to cover. The viewport screenshot golden had started depending
on the missing-model popup even though the test is only about restoring
an offscreen viewport.

Set `Comfy.Workflow.ShowMissingModelsWarning` to `false` in the shared
Playwright fixture, keep the missing-model dialog coverage by explicitly
enabling the setting in the dialog tests, and update the viewport
screenshot expectation to the no-popup rendering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9513-test-disable-missing-model-warnings-in-browser-tests-31b6d73d365081d1908bfe11ec0c3bc2)
by [Unito](https://www.unito.io)
2026-03-06 17:37:50 -08:00
Johnpaul Chiwetelu
c4dabb8f98 refactor: extract input widget resolution from SubgraphNode configure (#9383)
## Summary

Extract the inner link-resolution loop from
`_internalConfigureAfterSlots` into a private `_resolveInputWidget`
method to reduce cognitive complexity below the sonarjs threshold of 15.

## Changes

- **What**: Extract nested loop body (lines 654-689) into
`_resolveInputWidget` private method in `SubgraphNode.ts`
- Pure refactoring with no behavioral changes

## Review Focus

Straightforward extract-method refactoring. The new method contains the
exact same logic that was previously inline.

Fixes #9297

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9383-refactor-extract-input-widget-resolution-from-SubgraphNode-configure-3196d73d365081ba9124cfd0d312fcb0)
by [Unito](https://www.unito.io)
2026-03-06 23:24:49 +01:00
Alexander Brown
0b73285ca1 fix: extract and harden subgraph node ID deduplication (#9510)
## Summary

Extract and harden subgraph node ID deduplication to prevent widget
store key collisions when multiple subgraph copies share identical node
IDs.

## Changes

- **What**: Extract `deduplicateSubgraphNodeIds` from `LGraph.ts` into
`utils/subgraphDeduplication.ts`, decomposed into focused helpers
(`remapNodeIds`, `findNextAvailableId`, `patchSerialisedLinks`,
`patchPromotedWidgets`, `patchProxyWidgets`). Clone inputs internally so
caller data is never mutated. Add safety limit on ID search to prevent
unbounded loops. Add `console.warn` on remapped IDs matching existing
`ensureGlobalIdUniqueness` behavior. Add test fixture and 5 behavioral
tests covering ID remapping, link patching, promoted widget patching,
proxyWidget patching, and no-op when IDs are unique.

## Review Focus

- The cloning strategy in `deduplicateSubgraphNodeIds` — it
`structuredClone`s subgraphs and rootNodes, returning the clones. The
caller uses `effectiveNodesData` to thread the patched root nodes
through to node creation.
- The `MAX_NODE_ID` safety limit (100M) — is this a reasonable ceiling?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9510-fix-extract-and-harden-subgraph-node-ID-deduplication-31b6d73d365081f48c7de75e2bfc48b3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-06 21:56:56 +00:00
AustinMroz
7a01be388f More app fixes (#9432)
- Increased the z-index on app mode outputs so that they display above a
zoomed image
- The "view job" button on the job queued toast in mobile app mode will
take you to outputs instead of assets
- Image previews now have a minimum zoom of ~20% and a maximum zoom of
~50x
- The enter panel in linear mode now has a minimum size of ~1/5th screen
size
- In arrange mode, dragging to rearrange inputs will no longer cause a
horizontal scrollbar to appear.
- Videos will now display the first frame instead of a generic video
icon
- Muted/Bypassed nodes can no longer be selected as inputs/outputs, or
be displayed when in app mode.
- Linked input can no longer be selected or displayed
- Adds a share workflow button in app mode and wires up the existing
context menu

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9432-More-app-fixes-31a6d73d365081509cd0ea74bfdc9b95)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-06 13:41:52 -08:00
pythongosssss
3ddff9f7b6 feat: Update workflow menu to allow quick toggling modes (#9436)
## Summary

Adds a quick toggle mode button to the workflow menu for users to easier
discover & change modes

## Changes

- **What**: 
- remove specific app mode rendering
- increase spacing around breadcrumbs menu
- add current mode text to menu
- add base button variant

## Screenshots (if applicable)

<img width="258" height="137" alt="image"
src="https://github.com/user-attachments/assets/2ed7b276-c52c-44cd-b107-399f769574af"
/>
<img width="233" height="172" alt="image"
src="https://github.com/user-attachments/assets/2639d30c-2150-4434-a86b-732649c4b142"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9436-feat-Update-workflow-menu-to-allow-quick-toggling-modes-31a6d73d365081b589eee0e03cd6f1de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-06 20:03:02 +00:00
pythongosssss
4ff14b5eb9 feat/fix: App mode QA updates (#9439)
## Summary

Various fixes from app mode QA

## Changes

- **What**: 
- fix: prevent inserting nodes from workflow/apps sidebar tabs
- fix: hide json extension in workflow tab
- fix: hide apps nav button in apps tab when already in apps mode
- fix: center text on arrange page
- fix: prevent IoItems from "jumping" due to stale transform after drag
and drop op
- fix: refactor side panels and add custom stable pixel based sizing
- fix: make outputs/inputs lists in app builder scrollable
- fix: fix rerun not working correctly

- feat: add text to interrupt button
- feat: add enter app mode button to builder toolbar
- feat: add tooltip to download button on linear view
- feat: show last output of workflow in arrange tab if available
- feat: show download count in download all button, hide if only 1 asset
to download

## Review Focus

- Rerun - I am not sure why it was triggering widget actions, removing
it seemed like the correct fix
- useStablePrimeVueSplitter - this is a workaround for the fact it uses
percent sizing, I also tried switching to reka-ui splitters, but they
also only support % sizing in our version [pixel based looks to have
been added in a newer version, will log an issue to upgrade & replace
splitters with this]


## Screenshots (if applicable)

<img width="1314" height="1129" alt="image"
src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca"
/>
<img width="511" height="283" alt="image"
src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b"
/>
<img width="254" height="232" alt="image"
src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535"
/>
<img width="487" height="198" alt="image"
src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8"
/>
<img width="378" height="647" alt="image"
src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d"
/>
<img width="1016" height="476" alt="image"
src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817)
by [Unito](https://www.unito.io)
2026-03-06 20:02:19 +00:00
pythongosssss
bae1081a08 fix: update loadWorkflowInMedia test to only assert upload request URL (#9488)
## Summary

Fixes flakey test to only assert that the upload request is made with
the correct URL

## Changes

- **What**
- Replace waitForResponse with waitForRequest for the no_workflow.webp
upload test to only assert the request is initiated with the correct URL
- Move request listener setup before the drag-drop action to avoid race
conditions
- Remove screenshot assertion for the upload case since the upload may
not complete before the screenshot is taken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9488-fix-update-loadWorkflowInMedia-test-to-only-assert-upload-request-URL-31b6d73d365081f69a9aeb1095da7d60)
by [Unito](https://www.unito.io)
2026-03-06 11:38:53 -08:00
AustinMroz
55b8236c8d Fix localization on share and hide entry (#9395)
A placeholder share entry was added in #9368, but the localization for
this share label was then removed in #9361.

This localization is re-added in a location that is less likely to be
overwritten and the menu item is set to hidden. I'll manually connect it
to the workflow sharing feature flag in a followup PR after that has
been merged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9395-Fix-localization-on-share-and-hide-entry-3196d73d36508146a343f625a5327bdd)
by [Unito](https://www.unito.io)
2026-03-06 09:35:18 -08:00
Johnpaul Chiwetelu
5e17bbbf85 feat: expose litegraph internal keybindings (#9459)
## Summary

Migrate hardcoded litegraph canvas keybindings (Ctrl+A/C/V, Delete,
Backspace) into the customizable keybinding system so users can remap
them via Settings > Keybindings.

## Changes

- **What**: Register Ctrl+A (SelectAll), Ctrl+C (CopySelected), Ctrl+V
(PasteFromClipboard), Ctrl+Shift+V (PasteFromClipboardWithConnect),
Delete/Backspace (DeleteSelectedItems) as core keybindings in
`defaults.ts`. Add new `PasteFromClipboardWithConnect` command. Remove
hardcoded handling from litegraph `processKey()`, the `app.ts` Ctrl+C/V
monkey-patch, and the `keybindingService` canvas forwarding logic.

Fixes #1082
Fixes #2015

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9459-feat-expose-litegraph-internal-keybindings-31b6d73d3650819a8499fd96c8a6678f)
by [Unito](https://www.unito.io)
2026-03-06 18:30:35 +01:00
568 changed files with 32304 additions and 5841 deletions

View File

@@ -38,6 +38,9 @@ TEST_COMFYUI_DIR=/home/ComfyUI
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -13,6 +13,8 @@ on:
branches:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled]
workflow_dispatch:
permissions: {}
@@ -23,16 +25,35 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch when the 'preview' label is added.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
env:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
# Derive variant from all PR labels (default to cpu for frontend-only previews)
VARIANT="cpu"
echo "${PR_LABELS}" | grep -q '"preview-gpu"' && VARIANT="gpu"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
fi
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"

1
.gitignore vendored
View File

@@ -26,6 +26,7 @@ dist-ssr
.claude/*.local.json
.claude/*.local.md
.claude/*.local.txt
.claude/worktrees
CLAUDE.local.md
# Editor directories and files

View File

@@ -1,10 +1,7 @@
<template>
<div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
<div class="p-terminal size-full rounded-none p-2">
<div ref="terminalEl" class="terminal-host h-full" />
</div>
<Button
v-tooltip.left="{
@@ -16,7 +13,7 @@
size="small"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'opacity-0 pointer-events-none select-none': !isHovered
'pointer-events-none opacity-0 select-none': !isHovered
})
"
:aria-label="tooltipText"

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-8 select-none">
<!-- Installation Path Section -->
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
<div class="flex grow flex-col gap-6 text-neutral-300">
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100">
{{ $t('install.locationPicker.title') }}
</h2>
<p class="text-center text-neutral-400 px-12">
<p class="px-12 text-center text-neutral-400">
{{ $t('install.locationPicker.subtitle') }}
</p>
@@ -15,7 +15,7 @@
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
class="flex-1 border-neutral-700 bg-neutral-800/50 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
@@ -23,7 +23,7 @@
<Button
icon="pi pi-folder-open"
severity="secondary"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
class="border-0 bg-neutral-700 hover:bg-neutral-600"
@click="browsePath"
/>
</div>
@@ -33,7 +33,7 @@
<Message
v-if="pathError"
severity="error"
class="whitespace-pre-line w-full"
class="w-full whitespace-pre-line"
>
{{ pathError }}
</Message>

View File

@@ -26,7 +26,7 @@
<img
v-if="task.headerImg"
:src="task.headerImg"
class="h-full w-full object-contain px-4 pt-4 opacity-25"
class="size-full object-contain px-4 pt-4 opacity-25"
/>
</template>
<template #title>
@@ -52,7 +52,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 z-10 col-span-full row-span-full text-[4rem] text-green-500 opacity-100 transition-opacity [text-shadow:0.25rem_0_0.5rem_black] group-hover/task-card:opacity-20"
/>
</div>
</template>

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
<p class="w-full text-center text-neutral-400">
{{ $t('maintenance.allOk') }}
</p>
</template>
@@ -25,7 +25,7 @@
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useI18n } from 'vue-i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
@@ -55,6 +56,7 @@ import type {
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
detail: message ?? t('maintenance.error.defaultDescription')
})
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
<div class="flex size-full flex-col justify-between rounded-lg p-6">
<h1 class="m-0 font-inter text-xl font-semibold italic">
{{ $t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div
class="h-screen w-screen grid items-center justify-around overflow-y-auto"
class="grid h-screen w-screen items-center justify-around overflow-y-auto"
>
<div class="relative m-8 text-center">
<!-- Header -->
@@ -13,7 +13,7 @@
<span>{{ $t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 w-48 h-48" />
<ProgressSpinner class="m-8 size-48" />
<!-- Console button -->
<Button

View File

@@ -1,10 +1,10 @@
<template>
<BaseViewTemplate dark>
<!-- Fixed height container with flexbox layout for proper content management -->
<div class="w-full h-full flex flex-col">
<div class="flex size-full flex-col">
<Stepper
v-model:value="currentStep"
class="flex flex-col h-full"
class="flex h-full flex-col"
@update:value="handleStepChange"
>
<!-- Main content area that grows to fill available space -->
@@ -37,7 +37,7 @@
<!-- Install footer with navigation -->
<InstallFooter
class="w-full max-w-2xl my-6 mx-auto"
class="mx-auto my-6 w-full max-w-2xl"
:current-step
:can-proceed
:disable-location-step="noGpu"

View File

@@ -1,21 +1,21 @@
<template>
<BaseViewTemplate dark>
<div
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
class="dark-theme grid h-screen min-h-full w-screen min-w-full justify-around overflow-y-auto bg-neutral-900 font-sans text-neutral-300"
>
<div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
<div class="relative m-8 w-screen max-w-(--breakpoint-sm)">
<!-- Header -->
<h1 class="backspan pi-wrench text-4xl font-bold">
{{ t('maintenance.title') }}
</h1>
<!-- Toolbar -->
<div class="w-full flex flex-wrap gap-4 items-center">
<div class="flex w-full flex-wrap items-center gap-4">
<span class="grow">
{{ t('maintenance.status') }}:
<StatusTag :refreshing="isRefreshing" :error="anyErrors" />
</span>
<div class="flex gap-4 items-center">
<div class="flex items-center gap-4">
<SelectButton
v-model="displayAsList"
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
@@ -56,10 +56,10 @@
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
<strong class="mb-1 block">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
<span class="mb-1 block">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
@@ -71,13 +71,13 @@
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
class="border-x-0 border-y border-solid border-neutral-700"
:filter
:display-as-list
/>
<!-- Actions -->
<div class="flex justify-between gap-4 flex-row">
<div class="flex flex-row justify-between gap-4">
<Button
:label="t('maintenance.consoleLogs')"
icon="pi pi-desktop"
@@ -189,8 +189,7 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue'),
life: 5_000
detail: t('maintenance.error.cannotContinue')
})
}
}

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
>
<h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
class="text-blue-400 underline hover:text-blue-300"
>
{{ $t('install.privacyPolicy') }} </a
>.
@@ -33,7 +33,7 @@
}}
</span>
</div>
<div class="flex pt-6 justify-end">
<div class="flex justify-end pt-6">
<Button
:label="$t('g.ok')"
icon="pi pi-check"
@@ -72,8 +72,7 @@ const updateConsent = async () => {
toast.add({
severity: 'error',
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
detail: t('install.settings.errorUpdatingConsentDetail')
})
} finally {
isUpdating.value = false

View File

@@ -9,7 +9,7 @@
/>
<div class="no-drag sad-text flex items-center">
<div class="flex flex-col gap-8 p-8 min-w-110">
<div class="flex min-w-110 flex-col gap-8 p-8">
<!-- Header -->
<h1 class="text-4xl font-bold text-red-500">
{{ $t('notSupported.title') }}
@@ -20,7 +20,7 @@
<p class="text-xl">
{{ $t('notSupported.message') }}
</p>
<ul class="list-disc list-inside space-y-1 text-neutral-800">
<ul class="list-inside list-disc space-y-1 text-neutral-800">
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
</ul>

View File

@@ -2,14 +2,14 @@
<BaseViewTemplate dark>
<div class="relative min-h-screen">
<!-- Terminal Background Layer (always visible during loading) -->
<div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
<div class="h-full w-full">
<div v-if="!isError" class="fixed inset-0 z-0 overflow-hidden">
<div class="size-full">
<BaseTerminal @created="terminalCreated" />
</div>
</div>
<!-- Semi-transparent overlay -->
<div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
<div v-if="!isError" class="fixed inset-0 z-5 bg-neutral-900/80"></div>
<!-- Smooth radial gradient overlay -->
<div
@@ -45,9 +45,9 @@
<!-- Error Section (positioned at bottom) -->
<div
v-if="isError"
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
class="absolute inset-x-0 bottom-20 flex flex-col items-center gap-4"
>
<div class="flex gap-4 justify-center">
<div class="flex justify-center gap-4">
<Button
icon="pi pi-flag"
:label="$t('serverStart.reportIssue')"
@@ -71,10 +71,10 @@
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
<div
v-if="terminalVisible && isError"
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
class="absolute inset-x-4 bottom-4 z-10 mx-auto max-w-4xl"
>
<div
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
class="h-[300px] rounded-lg border border-neutral-700 bg-neutral-900/95 p-4"
>
<BaseTerminal @created="terminalCreated" />
</div>

View File

@@ -0,0 +1,47 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "KSampler",
"pos": [500, 50],
"size": [315, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -0,0 +1,172 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -0,0 +1,817 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 61,
"last_link_id": 70,
"nodes": [
{
"id": 35,
"type": "MarkdownNote",
"pos": [-424.0076397768001, 199.99406275798367],
"size": [510, 774],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ ├── 📂 text_encoders/\n│ │ └── qwen_3_4b.safetensors\n│ ├── 📂 loras/\n│ │ └── pixel_art_style_z_image_turbo.safetensors\n│ ├── 📂 diffusion_models/\n│ │ └── z_image_turbo_bf16.safetensors\n│ └── 📂 vae/\n│ └── ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": [569.9875743118757, 199.99406275798367],
"size": [780, 660],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 62
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z-image-turbo"]
},
{
"id": 57,
"type": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"pos": [128.01215102992103, 199.99406275798367],
"size": [400, 470],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "prompt",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [62]
}
],
"properties": {
"proxyWidgets": [
["27", "text"],
["13", "width"],
["13", "height"],
["28", "unet_name"],
["30", "clip_name"],
["29", "vae_name"],
["3", "steps"],
["3", "control_after_generate"]
],
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
}
],
"links": [[62, 57, 0, 9, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "f2fdebf6-dfaf-43b6-9eb2-7f70613cfdc1",
"version": 1,
"state": {
"lastGroupId": 4,
"lastNodeId": 61,
"lastLinkId": 70,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Text to Image (Z-Image-Turbo)",
"inputNode": {
"id": -10,
"bounding": [-80, 425, 120, 180]
},
"outputNode": {
"id": -20,
"bounding": [1490, 415, 120, 60]
},
"inputs": [
{
"id": "fb178669-e742-4a53-8a69-7df59834dfd8",
"name": "text",
"type": "STRING",
"linkIds": [34],
"label": "prompt",
"pos": [20, 445]
},
{
"id": "dd780b3c-23e9-46ff-8469-156008f42e5a",
"name": "width",
"type": "INT",
"linkIds": [35],
"pos": [20, 465]
},
{
"id": "7b08d546-6bb0-4ef9-82e9-ffae5e1ee6bc",
"name": "height",
"type": "INT",
"linkIds": [36],
"pos": [20, 485]
},
{
"id": "8ed4eb73-a2bf-4766-8bf4-c5890b560596",
"name": "unet_name",
"type": "COMBO",
"linkIds": [38],
"pos": [20, 505]
},
{
"id": "f362d639-d412-4b5d-8490-1e9995dc5f82",
"name": "clip_name",
"type": "COMBO",
"linkIds": [39],
"pos": [20, 525]
},
{
"id": "ee25ac16-de63-4b74-bbbb-5b29fdc1efcf",
"name": "vae_name",
"type": "COMBO",
"linkIds": [40],
"pos": [20, 545]
},
{
"id": "51cbcd61-9218-4bcb-89ac-ecdfb1ef8892",
"name": "steps",
"type": "INT",
"linkIds": [70],
"pos": [20, 565]
}
],
"outputs": [
{
"id": "1fa72a21-ce00-4952-814e-1f2ffbe87d1d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [16],
"localized_name": "IMAGE",
"pos": [1510, 435]
}
],
"widgets": [],
"nodes": [
{
"id": 30,
"type": "CLIPLoader",
"pos": [110, 330],
"size": [270, 106],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "clip_name",
"name": "clip_name",
"type": "COMBO",
"widget": {
"name": "clip_name"
},
"link": 39
}
],
"outputs": [
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [28]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["qwen_3_4b.safetensors", "lumina2", "default"]
},
{
"id": 29,
"type": "VAELoader",
"pos": [110, 480],
"size": [270, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "vae_name",
"name": "vae_name",
"type": "COMBO",
"widget": {
"name": "vae_name"
},
"link": 40
}
],
"outputs": [
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [27]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["ae.safetensors"]
},
{
"id": 33,
"type": "ConditioningZeroOut",
"pos": [640, 620],
"size": [204.134765625, 26],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 32
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [33]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1220, 160],
"size": [210, 46],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 27
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [16]
}
],
"properties": {
"Node name for S&R": "VAEDecode",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 28,
"type": "UNETLoader",
"pos": [110, 200],
"size": [270, 82],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "unet_name",
"name": "unet_name",
"type": "COMBO",
"widget": {
"name": "unet_name"
},
"link": 38
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"links": [26]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": ["z_image_turbo_bf16.safetensors", "default"]
},
{
"id": 27,
"type": "CLIPTextEncode",
"pos": [430, 200],
"size": [410, 370],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 28
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 34
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [30, 32]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up. "
]
},
{
"id": 13,
"type": "EmptySD3LatentImage",
"pos": [110, 630],
"size": [260, 110],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "width",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": 35
},
{
"localized_name": "height",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": 36
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [17]
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [1024, 1024, 1]
},
{
"id": 11,
"type": "ModelSamplingAuraFlow",
"pos": [880, 160],
"size": [310, 60],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 26
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [3]
},
{
"id": 3,
"type": "KSampler",
"pos": [880, 270],
"size": [315, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 13
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 30
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 33
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 17
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": 70
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [14]
}
],
"properties": {
"Node name for S&R": "KSampler",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
0,
"randomize",
8,
1,
"res_multistep",
"simple",
1
]
}
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [100, 560, 290, 200],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [410, 130, 450, 540],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [100, 130, 290, 413.6],
"color": "#3f789e",
"flags": {}
}
],
"links": [
{
"id": 32,
"origin_id": 27,
"origin_slot": 0,
"target_id": 33,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 26,
"origin_id": 28,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 14,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 27,
"origin_id": 29,
"origin_slot": 0,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 13,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 30,
"origin_id": 27,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 33,
"origin_id": 33,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 17,
"origin_id": 13,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 28,
"origin_id": 30,
"origin_slot": 0,
"target_id": 27,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 16,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 34,
"origin_id": -10,
"origin_slot": 0,
"target_id": 27,
"target_slot": 1,
"type": "STRING"
},
{
"id": 35,
"origin_id": -10,
"origin_slot": 1,
"target_id": 13,
"target_slot": 0,
"type": "INT"
},
{
"id": 36,
"origin_id": -10,
"origin_slot": 2,
"target_id": 13,
"target_slot": 1,
"type": "INT"
},
{
"id": 38,
"origin_id": -10,
"origin_slot": 3,
"target_id": 28,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 39,
"origin_id": -10,
"origin_slot": 4,
"target_id": 30,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 40,
"origin_id": -10,
"origin_slot": 5,
"target_id": 29,
"target_slot": 0,
"type": "COMBO"
},
{
"id": 70,
"origin_id": -10,
"origin_slot": 6,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "LG"
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6488294314381271,
"offset": [733, 392.7886597938144]
},
"frontendVersion": "1.43.4",
"workflowRendererVersion": "LG",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -0,0 +1,599 @@
{
"id": "legacy-prefix-test-workflow",
"revision": 0,
"last_node_id": 5,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [["6", "6: 3: string_a"]]
},
"widgets_values": [""]
},
{
"id": 2,
"type": "PreviewAny",
"pos": [1335, 429],
"size": [250, 145.5],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [356, 450],
"size": [225, 121.5],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 6,
"lastLinkId": 9,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1315, 432.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1335, 452.5]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "StringConcatenate",
"pos": [815, 373],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [955, 775],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
},
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [313, 685],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 9,
"lastLinkId": 12,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [680, 774, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1320, 774, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [780, 794]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1340, 794]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "StringConcatenate",
"pos": [860, 719],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "PrimitiveStringMultiline",
"pos": [401, 973],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1046, 985],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [["-1", "string_a"]]
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 6,
"origin_slot": 0,
"target_id": 5,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 5,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Innermost Subgraph",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1330, 1222, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1350, 1242]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "StringConcatenate",
"pos": [870, 1038],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [442, 1296],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-7, 144]
},
"frontendVersion": "1.38.13"
},
"version": 0.4
}

View File

@@ -0,0 +1,555 @@
{
"id": "b04d981f-6857-48cc-ac6e-429ab2f6bc8d",
"revision": 0,
"last_node_id": 11,
"last_link_id": 16,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": [1451.0058559453123, 189.0019842294924],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 13
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [25.988896564209426, 473.9973077158204],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [11]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [10]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"pos": [711.776576770508, 420.55569028417983],
"size": [400, 293],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 10
},
{
"name": "model",
"type": "MODEL",
"link": 11
},
{
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [13]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"],
["3", "seed"]
]
},
"widgets_values": []
}
],
"links": [
[10, 4, 1, 10, 0, "CLIP"],
[11, 4, 0, 10, 1, "MODEL"],
[12, 4, 2, 10, 2, "VAE"],
[13, 10, 0, 9, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "d14ff4cf-e5cb-4c84-941f-7c2457476424",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233, 404.5, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1494, 424.5, 120, 60]
},
"inputs": [
{
"id": "85b7a46b-f14c-4297-8b86-3fc73a41da2b",
"name": "clip",
"type": "CLIP",
"linkIds": [14],
"localized_name": "clip",
"pos": [333, 424.5]
},
{
"id": "b4040cb7-0457-416e-ad6e-14890b871dd2",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [333, 444.5]
},
{
"id": "e61199fa-9113-4532-a3d9-879095969171",
"name": "vae",
"type": "VAE",
"linkIds": [8],
"localized_name": "vae",
"pos": [333, 464.5]
}
],
"outputs": [
{
"id": "a4705fa5-a5e6-4c4e-83c2-dfb875861466",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [9],
"localized_name": "IMAGE",
"pos": [1514, 444.5]
}
],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473.007643669922, 609.0214689174805],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [862.990643669922, 185.9853293300783],
"size": [400, 317],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 16
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 15
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209.0062878349609, 188.00400724755877],
"size": [400, 200],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 11,
"type": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"pos": [485.5190761650391, 283.9247189174806],
"size": [400, 237],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [15]
},
{
"localized_name": "CONDITIONING_1",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"links": [16]
}
],
"properties": {
"proxyWidgets": [
["7", "text"],
["6", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 5,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 8,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 2,
"target_id": 8,
"target_slot": 1,
"type": "VAE"
},
{
"id": 9,
"origin_id": 8,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 0,
"target_id": 11,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": 11,
"origin_slot": 0,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 16,
"origin_id": 11,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
},
{
"id": "3b9b7fb9-a8f6-4b4e-ac13-b68156afe8f6",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 16,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [233.01228575000005, 332.7902770140076, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [
898.2956109453125, 322.7902770140076, 138.31666564941406, 80
]
},
"inputs": [
{
"id": "e5074a9c-3b33-4998-b569-0638817e81e7",
"name": "clip",
"type": "CLIP",
"linkIds": [5, 3],
"localized_name": "clip",
"pos": [55, 20]
}
],
"outputs": [
{
"id": "5fd778da-7ff1-4a0b-9282-d11a2e332e15",
"name": "CONDITIONING",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "CONDITIONING",
"pos": [20, 20]
},
{
"id": "1e02089f-6491-45fa-aa0a-24458100f8ae",
"name": "CONDITIONING_1",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "CONDITIONING_1",
"pos": [20, 40]
}
],
"widgets": [],
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413.01228575000005, 388.98593823266606],
"size": [425, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [414.99053247091683, 185.9946096918335],
"size": [423, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
}
],
"groups": [],
"links": [
{
"id": 5,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 6,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "CONDITIONING"
},
{
"id": 4,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "CONDITIONING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6830134553650709,
"offset": [-203.70966200000038, 259.92420099999975]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -0,0 +1,407 @@
{
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
"revision": 0,
"last_node_id": 19,
"last_link_id": 24,
"nodes": [
{
"id": 14,
"type": "CLIPLoader",
"pos": [143.16716182216328, 290.16372862874033],
"size": [270, 117.3125],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [21]
}
],
"properties": {
"Node name for S&R": "CLIPLoader"
},
"widgets_values": [null, "stable_diffusion", "default"]
},
{
"id": 18,
"type": "PreviewImage",
"pos": [1305.1455526601603, 472.17095792625025],
"size": [225, 48],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 24
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 19,
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"pos": [794.198171390827, 452.45433419677147],
"size": [225, 172],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "renamed_clip",
"name": "clip",
"type": "CLIP",
"link": 21
},
{
"label": "renamed_seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 22
},
{
"label": "renamed_vae",
"name": "vae",
"type": "VAE",
"link": 23
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [24]
}
],
"title": "Input Test Subgraph",
"properties": {
"proxyWidgets": [
["12", "seed"],
["15", "text"]
]
},
"widgets_values": []
},
{
"id": 13,
"type": "PrimitiveInt",
"pos": [155.04048166054417, 773.3816055422594],
"size": [270, 82],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [22]
}
],
"title": "Seed Int",
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 17,
"type": "VAELoader",
"pos": [163.6043676075426, 543.9624492717659],
"size": [270, 82.65625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [23]
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": ["pixel_space"]
}
],
"links": [
[21, 14, 0, 19, 0, "CLIP"],
[22, 13, 0, 19, 1, "INT"],
[23, 17, 0, 19, 2, "VAE"],
[24, 19, 0, 18, 0, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 19,
"lastLinkId": 24,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Input Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [
358.8694807105848, 439.23932667242485, 123.14453125,
99.99999999999994
]
},
"outputNode": {
"id": -20,
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
},
"inputs": [
{
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
"name": "clip",
"type": "CLIP",
"linkIds": [16],
"localized_name": "clip",
"label": "renamed_clip",
"pos": [462.0140119605848, 459.23932667242485]
},
{
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
"name": "seed",
"type": "INT",
"linkIds": [15],
"localized_name": "seed",
"label": "renamed_seed",
"pos": [462.0140119605848, 479.23932667242485]
},
{
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
"name": "vae",
"type": "VAE",
"linkIds": [19],
"localized_name": "vae",
"label": "renamed_vae",
"pos": [462.0140119605848, 499.23932667242485]
}
],
"outputs": [
{
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [20],
"localized_name": "IMAGE",
"pos": [1428.5510580294986, 483.2512895126797]
}
],
"widgets": [],
"nodes": [
{
"id": 12,
"type": "KSampler",
"pos": [769.2424728654022, 512.726159169824],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 17
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [18]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 16,
"type": "VAEDecode",
"pos": [1208.5510580294986, 469.21581253470083],
"size": [140, 46],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 18
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 19
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [20]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 15,
"type": "CLIPTextEncode",
"pos": [681.4596332342014, 243.17567172890932],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 16
},
{
"label": "renamed_from_sidepanel",
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [17]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
}
],
"groups": [],
"links": [
{
"id": 17,
"origin_id": 15,
"origin_slot": 0,
"target_id": 12,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 18,
"origin_id": 12,
"origin_slot": 0,
"target_id": 16,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 0,
"target_id": 15,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 12,
"target_slot": 4,
"type": "INT"
},
{
"id": 19,
"origin_id": -10,
"origin_slot": 2,
"target_id": 16,
"target_slot": 1,
"type": "VAE"
},
{
"id": 20,
"origin_id": 16,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.6727925600199565,
"offset": [446.69747171876463, 99.95078257277316]
}
},
"version": 0.4
}

View File

@@ -36,14 +36,7 @@
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
"ver": "0.3.65"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},

View File

@@ -31,6 +31,7 @@ import { DragDropHelper } from './helpers/DragDropHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
@@ -174,6 +175,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -206,9 +208,7 @@ export class ComfyPage {
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.runButton = page
.getByTestId(TestIds.topbar.queueButton)
.getByRole('button', { name: 'Run' })
this.runButton = page.getByTestId(TestIds.topbar.queueButton)
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
@@ -219,6 +219,7 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)
@@ -432,7 +433,10 @@ export const comfyPageFixture = base.extend<{
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true
'Comfy.VersionCompatibility.DisableWarnings': true,
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
})
} catch (e) {
console.error(e)

View File

@@ -172,6 +172,19 @@ export class VueNodeHelpers {
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
await editButton.click()
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
}

View File

@@ -0,0 +1,128 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.nextFrame()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
}

View File

@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -6,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -322,4 +324,93 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -33,6 +33,7 @@ export const TestIds = {
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button'
},
nodeLibrary: {
@@ -51,11 +52,17 @@ export const TestIds = {
colorRed: 'red'
},
widgets: {
container: 'node-widgets',
widget: 'node-widget',
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -82,6 +89,7 @@ export type TestIdValue =
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],

View File

@@ -29,8 +29,10 @@ class ComfyQueueButton {
public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
this.primaryButton = this.root
this.dropdownButton = actionbar.root.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
}
public async toggleOptions() {

View File

@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -48,6 +75,26 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
import { isSubgraph } from '../../src/utils/typeGuardUtil'
@@ -14,3 +16,30 @@ export function assertSubgraph(
)
}
}
/**
* Returns the widget-input slot Y position and the node title height
* for the promoted "text" input on the SubgraphNode.
*
* The slot Y should be at the widget row, not the header. A value near
* zero or negative indicates the slot is positioned at the header (the bug).
*/
export function getTextSlotPosition(page: Page, nodeId: string) {
return page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) return null
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
for (const input of node.inputs) {
if (!input.widget || input.type !== 'STRING') continue
return {
hasPos: !!input.pos,
posY: input.pos?.[1] ?? null,
widgetName: input.widget.name,
titleHeight
}
}
return null
}, nodeId)
}

View File

@@ -0,0 +1,149 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
await page.mouse.click(seedPos.x, seedPos.y)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
const saveImagePos = await saveImageRef.getPosition()
// Click left edge — the right side is hidden by the builder panel
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

@@ -89,6 +89,17 @@ test.describe('Execution error', () => {
})
test.describe('Missing models warning', () => {
test('Should be disabled by default in browser tests', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Group Copy Paste', { tag: ['@canvas'] }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('Pasted group is offset from original position', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/single_group_only')
const titlePos = await comfyPage.page.evaluate(() => {
const app = window.app!
const group = app.graph.groups[0]
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
})
await comfyPage.canvas.click({ position: titlePos })
await comfyPage.nextFrame()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await comfyPage.nextFrame()
const positions = await comfyPage.page.evaluate(() =>
window.app!.graph.groups.map((g: { pos: number[] }) => ({
x: g.pos[0],
y: g.pos[1]
}))
)
expect(positions).toHaveLength(2)
const dx = Math.abs(positions[0].x - positions[1].x)
const dy = Math.abs(positions[0].y - positions[1].y)
expect(dx).toBeCloseTo(50, 0)
expect(dy).toBeCloseTo(15, 0)
})
})

View File

@@ -0,0 +1,58 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Image paste priority over stale node metadata',
{ tag: ['@node'] },
() => {
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBe(2)
// Copy the KSampler node (puts data-metadata in clipboard)
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
// Select the LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
// Simulate pasting when clipboard has stale node metadata (text/html
// with data-metadata) but no image file items. This replicates the bug
// scenario: user copied a node, then copied a web image (which replaces
// clipboard files but may leave stale text/html with node metadata).
await comfyPage.page.evaluate(() => {
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
const base64 = btoa(JSON.stringify(nodeData))
const html =
'<meta charset="utf-8"><div><span data-metadata="' +
base64 +
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/html', html)
const event = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(event)
})
await comfyPage.nextFrame()
// Node count should remain the same — stale node metadata should NOT
// be deserialized when a media node is selected.
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(finalCount).toBe(initialCount)
})
}
)

View File

@@ -10,6 +10,7 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { WorkspaceStore } from '../types/globals'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -720,6 +721,19 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Creates initial workflow tab when persistence is disabled', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false)
await comfyPage.setup()
const openCount = await comfyPage.page.evaluate(() => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.openWorkflows.length
})
expect(openCount).toBeGreaterThanOrEqual(1)
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {
@@ -764,13 +778,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -819,16 +833,89 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
const workflowPathA = `${workflowA}.json`
const workflowPathB = `${workflowB}.json`
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowPathA, workflowPathB])
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowPathB)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})

View File

@@ -35,18 +35,21 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
const shouldUpload = filesWithUpload.has(fileName)
const uploadRequestPromise = shouldUpload
? comfyPage.page.waitForRequest((req) =>
req.url().includes('/upload/')
)
: null
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
if (uploadRequestPromise) {
const request = await uploadRequestPromise
expect(request.url()).toContain('/upload/')
} else {
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
}
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,104 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { Position } from '../fixtures/types'
type NodeSnapshot = { id: number } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
x: n.pos[0],
y: n.pos[1]
}))
)
}
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)
if (!node) return
return {
x: node.pos[0],
y: node.pos[1]
}
}, nodeId)
}
async function expectNodePositionStable(
comfyPage: ComfyPage,
initial: NodeSnapshot,
mode: string
) {
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.x ?? Number.NaN
},
{ message: `node ${initial.id} x drifted in ${mode} mode` }
)
.toBeCloseTo(initial.x, 1)
await expect
.poll(
async () => {
const current = await getNodePosition(comfyPage, initial.id)
return current?.y ?? Number.NaN
},
{ message: `node ${initial.id} y drifted in ${mode} mode` }
)
.toBeCloseTo(initial.y, 1)
}
async function setVueMode(comfyPage: ComfyPage, enabled: boolean) {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', enabled)
if (enabled) {
await comfyPage.vueNodes.waitForNodes()
}
await comfyPage.nextFrame()
}
test.describe(
'Renderer toggle stability',
{ tag: ['@node', '@canvas'] },
() => {
test('node positions do not drift when toggling between Vue and LiteGraph renderers', async ({
comfyPage
}) => {
const TOGGLE_COUNT = 5
const initialPositions = await getAllNodePositions(comfyPage)
expect(initialPositions.length).toBeGreaterThan(0)
for (let i = 0; i < TOGGLE_COUNT; i++) {
await setVueMode(comfyPage, true)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`Vue toggle ${i + 1}`
)
}
await setVueMode(comfyPage, false)
for (const initial of initialPositions) {
await expectNodePositionStable(
comfyPage,
initial,
`LiteGraph toggle ${i + 1}`
)
}
}
})
}
)

View File

@@ -13,9 +13,9 @@ test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
})
test('loads from inserted workflow', async ({ comfyPage }) => {
const workflowName = 'single_connected_reroute_node.json'
const workflowName = 'single_connected_reroute_node'
await comfyPage.workflow.setupWorkflowsDirectory({
[workflowName]: 'links/single_connected_reroute_node.json'
[`${workflowName}.json`]: `links/${workflowName}.json`
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -21,14 +21,12 @@ test.describe('Workflows sidebar', () => {
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
'*Unsaved Workflow',
'*Unsaved Workflow (2)'
])
})
@@ -41,37 +39,37 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
expect.arrayContaining(['workflow1', 'workflow2'])
)
})
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json'])
expect.arrayContaining(['workflow1'])
)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json'
'workflow1',
'*workflow1 (Copy)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json',
'*workflow1 (Copy) (3).json'
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
])
})
@@ -85,12 +83,12 @@ test.describe('Workflows sidebar', () => {
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await tab.insertWorkflow(tab.getPersistedItem('workflow1'))
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1.json').click()
await tab.getPersistedItem('workflow1').click()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
@@ -113,22 +111,22 @@ test.describe('Workflows sidebar', () => {
const openedWorkflow = tab.getOpenedItem('foo/bar')
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'foo/baz.json'
'*Unsaved Workflow',
'foo/baz'
])
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
.toEqual(['*Unsaved Workflow', 'workflow3'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow4')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
.toEqual(['*Unsaved Workflow', 'workflow3', 'workflow4'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -184,15 +182,15 @@ test.describe('Workflows sidebar', () => {
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
await comfyPage.menu.topbar.saveWorkflow('workflow5')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
await comfyPage.confirmDialog.click('overwrite')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5.json'
'workflow5'
])
})
@@ -212,25 +210,25 @@ test.describe('Workflows sidebar', () => {
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await topbar.saveWorkflow('workflow1')
await topbar.saveWorkflowAs('workflow2')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1.json', 'workflow2.json'])
.toEqual(['workflow1', 'workflow2'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2.json')
.toEqual('workflow2')
await topbar.saveWorkflowAs('workflow1.json')
await topbar.saveWorkflowAs('workflow1')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1.json should be deleted and the new one should be saved.
// The old workflow1 should be deleted and the new one should be saved.
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2.json', 'workflow1.json'])
.toEqual(['workflow2', 'workflow1'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1.json')
.toEqual('workflow1')
})
test('Does not report warning when switching between opened workflows', async ({
@@ -266,17 +264,15 @@ test.describe('Workflows sidebar', () => {
)
await closeButton.click()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
})
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
@@ -284,7 +280,7 @@ test.describe('Workflows sidebar', () => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -295,14 +291,14 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
test('Can delete workflows', async ({ comfyPage }) => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18.json'
const filename = 'workflow18'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -314,7 +310,7 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
'*Unsaved Workflow'
])
})
@@ -326,13 +322,11 @@ test.describe('Workflows sidebar', () => {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await workflowsTab.getPersistedItem('workflow1').click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
@@ -344,7 +338,7 @@ test.describe('Workflows sidebar', () => {
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -361,7 +355,7 @@ test.describe('Workflows sidebar', () => {
}
await comfyPage.page.dragAndDrop(
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
'.comfyui-workflows-browse .node-label:has-text("workflow1")',
'#graph-canvas',
{ targetPosition }
)

View File

@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,86 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
test.describe(
'Subgraph promoted widget-input slot position',
{ tag: '@subgraph' },
() => {
test('Promoted text widget slot is positioned at widget row, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Render a few frames so arrange() runs
await comfyPage.nextFrame()
await comfyPage.nextFrame()
const result = await getTextSlotPosition(comfyPage.page, '11')
expect(result).not.toBeNull()
expect(result!.hasPos).toBe(true)
// The slot Y position should be well below the title area.
// If it's near 0 or negative, the slot is stuck at the header (the bug).
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
})
test('Slot position remains correct after renaming subgraph input label', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
// Verify initial position is correct
const before = await getTextSlotPosition(comfyPage.page, '11')
expect(before).not.toBeNull()
expect(before!.hasPos).toBe(true)
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
// Navigate into subgraph and rename the text input
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const initialLabel = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
const textInput = graph.inputs?.find(
(i: { type: string }) => i.type === 'STRING'
)
return textInput?.label || textInput?.name || null
})
if (!initialLabel)
throw new Error('Could not find STRING input in subgraph')
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
await comfyPage.page.fill(dialog, '')
await comfyPage.page.fill(dialog, 'my_custom_prompt')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
// Navigate back to parent graph
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify slot position is still at the widget row after rename
const after = await getTextSlotPosition(comfyPage.page, '11')
expect(after).not.toBeNull()
expect(after!.hasPos).toBe(true)
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
// widget.name is the stable identity key — it does NOT change on rename.
// The display label is on input.label, read via PromotedWidgetView.label.
expect(after!.widgetName).not.toBe('my_custom_prompt')
})
}
)

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
test.describe(
'Subgraph promoted widget DOM position',
{ tag: '@subgraph' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// Enable Vue nodes now that the subgraph has been created
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(
comfyPage,
subgraphNodeId
)
expect(promotedNames).toContain('seed')
// Wait for Vue nodes to render
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
// The seed widget should be visible inside the node body
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
// Verify widget is inside the node body, not the header
const headerBox = await nodeLocator
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
// Widget top should be below the header bottom
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
})
}
)

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -631,6 +632,51 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -0,0 +1,117 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
const RENAMED_LABEL = 'my_seed'
/**
* Regression test for subgraph input slot rename propagation.
*
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
* update the promoted widget label shown on the parent SubgraphNode and
* keep the widget positioned in the node body (not the header).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
*/
test.describe(
'Subgraph input slot rename propagation',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
comfyPage
}) => {
const { page } = comfyPage
// 1. Load workflow with subgraph containing a promoted seed widget input
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNode).toBeVisible()
// 2. Verify the seed widget is visible on the parent node
const seedWidget = sgNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
// Verify widget is in the node body, not the header
const headerBox = await sgNode
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetBox = await seedWidget.boundingBox()
expect(headerBox).not.toBeNull()
expect(widgetBox).not.toBeNull()
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
// 3. Enter the subgraph and rename the seed slot.
// The subgraph IO rename uses canvas.prompt() which requires the
// litegraph context menu, so temporarily disable Vue nodes.
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
await sgNodeRef.navigateIntoSubgraph()
// Find the seed SubgraphInput slot
const seedSlotName = await page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph) return null
const inputs = (
graph as { inputs?: Array<{ name: string; type: string }> }
).inputs
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
})
expect(seedSlotName).not.toBeNull()
// 4. Right-click the seed input slot and rename it
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
const dialog = '.graphdialog input'
await page.waitForSelector(dialog, { state: 'visible' })
await page.fill(dialog, '')
await page.fill(dialog, RENAMED_LABEL)
await page.keyboard.press('Enter')
await page.waitForSelector(dialog, { state: 'hidden' })
// 5. Navigate back to parent graph and re-enable Vue nodes
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// 6. Verify the widget label updated to the renamed value
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
await expect(sgNodeAfter).toBeVisible()
const updatedLabel = await page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('19')
if (!node) return null
const w = node.widgets?.find((w: { name: string }) =>
w.name.includes('seed')
)
return w?.label || w?.name || null
})
expect(updatedLabel).toBe(RENAMED_LABEL)
// 7. Verify the widget is still in the body, not the header
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
await expect(seedWidgetAfter).toBeVisible()
const headerAfter = await sgNodeAfter
.locator('[data-testid^="node-header-"]')
.boundingBox()
const widgetAfter = await seedWidgetAfter.boundingBox()
expect(headerAfter).not.toBeNull()
expect(widgetAfter).not.toBeNull()
expect(widgetAfter!.y).toBeGreaterThan(
headerAfter!.y + headerAfter!.height
)
})
}
)

View File

@@ -0,0 +1,99 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy prefixed proxyWidget normalization',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('Failed to resolve legacy -1') ||
text.includes('No link found') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('Promoted widget renders with normalized name, not legacy prefix', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The promoted widget should render with the clean name "string_a",
// not the legacy-prefixed "6: 3: string_a".
const promotedWidget = outerNode
.getByLabel('string_a', { exact: true })
.first()
await expect(promotedWidget).toBeVisible()
})
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// Both widget rows should be valid "string_a" widgets — no stale
// "Disconnected" placeholders from unresolved legacy entries.
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(row.getByLabel('string_a', { exact: true })).toBeVisible()
}
})
test('Promoted widget value is editable as a text input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
const textarea = outerNode
.getByRole('textbox', { name: 'string_a' })
.first()
await expect(textarea).toBeVisible()
})
}
)

View File

@@ -0,0 +1,347 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
for (const exists of results) {
expect(exists).toBe(true)
}
}
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph cleans up exterior proxyWidgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: 0,
firstWidgetType: undefined
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
}
)

View File

@@ -0,0 +1,110 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('No link found') ||
text.includes('Failed to resolve legacy -1') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
}
})
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})

View File

@@ -0,0 +1,141 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -0,0 +1,195 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Regression test for PR #10532:
* Packing all nodes inside a subgraph into a nested subgraph was causing
* the parent subgraph node's promoted widget values to go blank.
*
* Root cause: SubgraphNode had two sets of PromotedWidgetView references —
* node.widgets (rebuilt from the promotion store) vs input._widget (cached
* at promotion time). After repointing, input._widget still pointed to
* removed node IDs, causing missing-node failures and blank values on the
* next checkState cycle.
*/
test.describe(
'Nested subgraph pack preserves promoted widget values',
{ tag: ['@subgraph', '@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
const HOST_NODE_ID = '57'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// 1. Verify initial promoted widget values via Vue node DOM
const widthWidget = nodeLocator
.getByLabel('width', { exact: true })
.first()
const heightWidget = nodeLocator
.getByLabel('height', { exact: true })
.first()
const stepsWidget = nodeLocator
.getByLabel('steps', { exact: true })
.first()
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
const widthControls =
comfyPage.vueNodes.getInputNumberControls(widthWidget)
const heightControls =
comfyPage.vueNodes.getInputNumberControls(heightWidget)
const stepsControls =
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
await expect(async () => {
await expect(widthControls.input).toHaveValue('1024')
await expect(heightControls.input).toHaveValue('1024')
await expect(stepsControls.input).toHaveValue('8')
await expect(textWidget).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
// 2. Enter the subgraph via Vue node button
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
// 3. Disable Vue nodes for canvas operations (select all + convert)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// 4. Select all interior nodes and convert to nested subgraph
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
// 5. Navigate back to root graph and trigger a checkState cycle
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// 6. Re-enable Vue nodes and verify values are preserved
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeAfter).toBeVisible()
const widthAfter = nodeAfter.getByLabel('width', { exact: true }).first()
const heightAfter = nodeAfter
.getByLabel('height', { exact: true })
.first()
const stepsAfter = nodeAfter.getByLabel('steps', { exact: true }).first()
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
const widthControlsAfter =
comfyPage.vueNodes.getInputNumberControls(widthAfter)
const heightControlsAfter =
comfyPage.vueNodes.getInputNumberControls(heightAfter)
const stepsControlsAfter =
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
await expect(async () => {
await expect(widthControlsAfter.input).toHaveValue('1024')
await expect(heightControlsAfter.input).toHaveValue('1024')
await expect(stepsControlsAfter.input).toHaveValue('8')
await expect(textAfter).toHaveValue(/Latina female/)
}).toPass({ timeout: 5000 })
})
test('proxyWidgets entries resolve to valid interior nodes after packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Verify the host node is visible
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(nodeLocator).toBeVisible()
// Enter the subgraph via Vue node button, then disable for canvas ops
await comfyPage.vueNodes.enterSubgraph(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
await comfyPage.canvas.click()
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.graph!.convertToSubgraph(canvas.selectedItems)
})
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
// Verify all proxyWidgets entries resolve
await expect(async () => {
const result = await comfyPage.page.evaluate((hostId) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(hostId)
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
) {
return { error: 'Host node not found or not a subgraph node' }
}
const proxyWidgets = hostNode.properties?.proxyWidgets ?? []
const entries = (proxyWidgets as unknown[])
.filter(
(e): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string' &&
!e[1].startsWith('$$')
)
.map(([nodeId, widgetName]) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(nodeId))
return {
nodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return { entries, count: entries.length }
}, HOST_NODE_ID)
expect(result).not.toHaveProperty('error')
const { entries, count } = result as {
entries: { nodeId: string; widgetName: string; resolved: boolean }[]
count: number
}
expect(count).toBeGreaterThan(0)
for (const entry of entries) {
expect(
entry.resolved,
`Widget "${entry.widgetName}" (node ${entry.nodeId}) should resolve`
).toBe(true)
}
}).toPass({ timeout: 5000 })
})
}
)

View File

@@ -0,0 +1,51 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
/**
* Regression test for nested subgraph packing leaving stale proxyWidgets
* on the outer SubgraphNode.
*
* When two CLIPTextEncode nodes (ids 6, 7) inside the outer subgraph are
* packed into a nested subgraph (node 11), the outer SubgraphNode (id 10)
* must drop the now-stale ["7","text"] and ["6","text"] proxy entries.
* Only ["3","seed"] (KSampler) should remain.
*
* Stale entries render as "Disconnected" placeholder widgets (type "button").
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10390
*/
test.describe(
'Nested subgraph stale proxyWidgets',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(outerNode).toBeVisible()
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
// Only the KSampler seed widget should be present — no stale
// "Disconnected" placeholders from the packed CLIPTextEncode nodes.
await expect(widgets).toHaveCount(1)
await expect(widgets.first()).toBeVisible()
// Verify the seed widget is present via its label
const seedWidget = outerNode.getByLabel('seed', { exact: true })
await expect(seedWidget).toBeVisible()
})
}
)

View File

@@ -0,0 +1,131 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId!)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
@@ -12,26 +11,6 @@ import {
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -179,7 +158,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -251,7 +230,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -285,7 +264,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -331,7 +310,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -366,7 +345,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -397,7 +376,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -492,6 +471,30 @@ test.describe(
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
@@ -649,6 +652,44 @@ test.describe(
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
@@ -671,15 +712,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -2,9 +2,116 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../fixtures/ComfyPage'
const CREATE_GROUP_HOTKEY = 'Control+g'
type NodeGroupCenteringError = {
horizontal: number
vertical: number
}
type NodeGroupCenteringErrors = {
innerGroup: NodeGroupCenteringError
outerGroup: NodeGroupCenteringError
}
const LEGACY_VUE_CENTERING_BASELINE: NodeGroupCenteringErrors = {
innerGroup: {
horizontal: 16.308832840862777,
vertical: 17.390899314547084
},
outerGroup: {
horizontal: 20.30164329441476,
vertical: 42.196324096481476
}
} as const
const CENTERING_TOLERANCE = {
innerGroup: 6,
outerGroup: 12
} as const
function expectWithinBaseline(
actual: number,
baseline: number,
tolerance: number
) {
expect(Math.abs(actual - baseline)).toBeLessThan(tolerance)
}
async function getNodeGroupCenteringErrors(
comfyPage: ComfyPage
): Promise<NodeGroupCenteringErrors> {
return comfyPage.page.evaluate(() => {
type GraphNode = {
id: number | string
pos: ReadonlyArray<number>
}
type GraphGroup = {
title: string
pos: ReadonlyArray<number>
size: ReadonlyArray<number>
}
const app = window.app!
const node = app.graph.nodes[0] as GraphNode | undefined
if (!node) {
throw new Error('Expected a node in the loaded workflow')
}
const nodeElement = document.querySelector<HTMLElement>(
`[data-node-id="${node.id}"]`
)
if (!nodeElement) {
throw new Error(`Vue node element not found for node ${node.id}`)
}
const groups = app.graph.groups as GraphGroup[]
const innerGroup = groups.find((group) => group.title === 'Inner Group')
const outerGroup = groups.find((group) => group.title === 'Outer Group')
if (!innerGroup || !outerGroup) {
throw new Error('Expected both Inner Group and Outer Group in graph')
}
const nodeRect = nodeElement.getBoundingClientRect()
const getCenteringError = (group: GraphGroup): NodeGroupCenteringError => {
const [groupStartX, groupStartY] = app.canvasPosToClientPos([
group.pos[0],
group.pos[1]
])
const [groupEndX, groupEndY] = app.canvasPosToClientPos([
group.pos[0] + group.size[0],
group.pos[1] + group.size[1]
])
const groupLeft = Math.min(groupStartX, groupEndX)
const groupRight = Math.max(groupStartX, groupEndX)
const groupTop = Math.min(groupStartY, groupEndY)
const groupBottom = Math.max(groupStartY, groupEndY)
const leftGap = nodeRect.left - groupLeft
const rightGap = groupRight - nodeRect.right
const topGap = nodeRect.top - groupTop
const bottomGap = groupBottom - nodeRect.bottom
return {
horizontal: Math.abs(leftGap - rightGap),
vertical: Math.abs(topGap - bottomGap)
}
}
return {
innerGroup: getCenteringError(innerGroup),
outerGroup: getCenteringError(outerGroup)
}
})
}
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -74,4 +181,45 @@ test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('groups/nested-groups-1-inner-node')
await comfyPage.vueNodes.waitForNodes(1)
const workflowRendererVersion = await comfyPage.page.evaluate(() => {
const extra = window.app!.graph.extra as
| { workflowRendererVersion?: string }
| undefined
return extra?.workflowRendererVersion
})
expect(workflowRendererVersion).toMatch(/^Vue/)
await expect(async () => {
const centeringErrors = await getNodeGroupCenteringErrors(comfyPage)
expectWithinBaseline(
centeringErrors.innerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.horizontal,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.innerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.innerGroup.vertical,
CENTERING_TOLERANCE.innerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.horizontal,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.horizontal,
CENTERING_TOLERANCE.outerGroup
)
expectWithinBaseline(
centeringErrors.outerGroup.vertical,
LEGACY_VUE_CENTERING_BASELINE.outerGroup.vertical,
CENTERING_TOLERANCE.outerGroup
)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -22,8 +22,10 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -41,8 +43,14 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const ksamplerNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'KSampler' })
.getByTestId('node-inner-wrapper')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-node-stroke-error/
const ERROR_CLASS = /ring-destructive-background/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,9 +18,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
@@ -31,7 +32,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,102 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Advanced Widget Visibility', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
false
)
// Add a ModelSamplingFlux node which has both advanced (max_shift,
// base_shift) and non-advanced (width, height) widgets.
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
node.pos = [500, 200]
window.app!.graph.add(node)
})
await comfyPage.vueNodes.waitForNodes()
})
function getNode(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
}
function getWidgets(
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
) {
return getNode(comfyPage).locator('.lg-node-widget')
}
test('should hide advanced widgets by default', async ({ comfyPage }) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
// Non-advanced widgets (width, height) should be visible
await expect(widgets).toHaveCount(2)
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
// Advanced widgets should not be rendered
await expect(
node.getByLabel('max_shift', { exact: true })
).not.toBeVisible()
await expect(
node.getByLabel('base_shift', { exact: true })
).not.toBeVisible()
// "Show advanced inputs" button should be present
await expect(node.getByText('Show advanced inputs')).toBeVisible()
})
test('should show advanced widgets when per-node toggle is clicked', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Click the toggle button to show advanced widgets
await node.getByText('Show advanced inputs').click()
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// Button text should change to "Hide advanced inputs"
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
// Click again to hide
await node.getByText('Hide advanced inputs').click()
await expect(widgets).toHaveCount(2)
})
test('should show advanced widgets when global setting is enabled', async ({
comfyPage
}) => {
const node = getNode(comfyPage)
const widgets = getWidgets(comfyPage)
await expect(widgets).toHaveCount(2)
// Enable the global setting
await comfyPage.settings.setSetting(
'Comfy.Node.AlwaysShowAdvancedWidgets',
true
)
// All 4 widgets should now be visible
await expect(widgets).toHaveCount(4)
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
// The toggle button should not be shown when global setting is active
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
})
})

View File

@@ -0,0 +1,71 @@
# 7. NodeExecutionOutput Passthrough Schema Design
Date: 2026-03-11
## Status
Accepted
## Context
`NodeExecutionOutput` represents the output data from a ComfyUI node execution. The backend API is intentionally open-ended: custom nodes can output any key (`gifs`, `3d`, `meshes`, `point_clouds`, etc.) alongside the well-known keys (`images`, `audio`, `video`, `animated`, `text`).
The Zod schema uses `.passthrough()` to allow unknown keys through without validation:
```ts
const zOutputs = z
.object({
audio: z.array(zResultItem).optional(),
images: z.array(zResultItem).optional(),
video: z.array(zResultItem).optional(),
animated: z.array(z.boolean()).optional(),
text: z.union([z.string(), z.array(z.string())]).optional()
})
.passthrough()
```
This means unknown keys are typed as `unknown` in TypeScript, requiring runtime validation when iterating over all output entries (e.g., to build a unified media list).
### Why not `.catchall(z.array(zResultItem))`?
`.catchall()` correctly handles this at the Zod runtime level — explicit keys override the catchall, so `animated: [true]` parses fine even when the catchall expects `ResultItem[]`.
However, TypeScript's type inference creates an index signature `[k: string]: ResultItem[]` that **conflicts** with the explicit fields `animated: boolean[]` and `text: string | string[]`. These types don't extend `ResultItem[]`, so TypeScript errors on any assignment.
This is a TypeScript limitation, not a Zod or schema design issue. TypeScript cannot express "index signature applies only to keys not explicitly defined."
### Why not remove `animated` and `text` from the schema?
- `animated` is consumed by `isAnimatedOutput()` in `litegraphUtil.ts` and by `litegraphService.ts` to determine whether to render images as static or animated. Removing it would break typing for the graph editor path.
- `text` is part of the `zExecutedWsMessage` validation pipeline. Removing it from `zOutputs` would cause `.catchall()` to reject `{ text: "hello" }` as invalid (it's not `ResultItem[]`).
- Both are structurally different from media outputs — they are metadata, not file references. Mixing them in the same object is a backend API constraint we cannot change here.
## Decision
1. **Keep `.passthrough()`** on `zOutputs`. It correctly reflects the extensible nature of the backend API.
2. **Use `resultItemType` (the Zod enum) for `type` field validation** in the shared `isResultItem` guard. We cannot use `zResultItem.safeParse()` directly because the Zod schema marks `filename` and `subfolder` as `.optional()` (matching the wire format), but a `ResultItemImpl` needs both fields to construct a valid preview URL. The shared guard requires `filename` and `subfolder` as strings while delegating `type` validation to the Zod enum.
3. **Accept the `unknown[]` cast** when iterating passthrough entries. The cast is honest — passthrough values genuinely are `unknown`, and runtime validation narrows them correctly.
4. **Centralize the `NodeExecutionOutput → ResultItemImpl[]` conversion** into a shared utility (`parseNodeOutput` / `parseTaskOutput` in `src/stores/resultItemParsing.ts`) to eliminate duplicated, inconsistent validation across `flattenNodeOutput.ts`, `jobOutputCache.ts`, and `queueStore.ts`.
## Consequences
### Positive
- Single source of truth for `ResultItem` validation (shared `isResultItem` guard using Zod's `resultItemType` enum)
- Consistent validation strictness across all code paths
- Clear documentation of why `.passthrough()` is intentional, preventing future "fix" attempts
- The `unknown[]` cast is contained to one location
### Negative
- Manual `isResultItem` guard is stricter than `zResultItem` Zod schema (requires `filename` and `subfolder`); if the Zod schema changes, the guard must be updated manually
- The `unknown[]` cast remains necessary — cannot be eliminated without a TypeScript language change or backend API restructuring
## Notes
The backend API's extensible output format is a deliberate design choice for ComfyUI's plugin architecture. Custom nodes define their own output types, and the frontend must handle arbitrary keys gracefully. Any future attempt to make the schema stricter must account for this extensibility requirement.
If TypeScript adds support for "rest index signatures" or "exclusive index signatures" in the future, `.catchall()` could replace `.passthrough()` and the `unknown[]` cast would be eliminated.

View File

@@ -8,13 +8,15 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| --------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
## Creating a New ADR

View File

@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
const commonGlobals = {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly',
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
__DISTRIBUTION__: 'readonly',
__IS_NIGHTLY__: 'readonly'
} as const

2
global.d.ts vendored
View File

@@ -1,4 +1,5 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __COMFYUI_FRONTEND_COMMIT__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
@@ -35,6 +36,7 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -47,6 +47,60 @@
border: 0;
}
</style>
<style>
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
index.html instead of CSS on cloud/ephemeral environments */
#splash-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
contain: strict;
}
#splash-loader svg {
width: min(200px, 50vw);
height: auto;
transform: translateZ(0);
}
#splash-loader .wave-group {
animation: splash-rise 4s ease-in-out infinite alternate;
will-change: transform;
transform: translateZ(0);
}
#splash-loader .wave-path {
animation: splash-wave 1.2s linear infinite;
will-change: transform;
transform: translateZ(0);
}
@keyframes splash-rise {
from {
transform: translateY(280px);
}
to {
transform: translateY(-80px);
}
}
@keyframes splash-wave {
from {
transform: translateX(0);
}
to {
transform: translateX(-880px);
}
}
@media (prefers-reduced-motion: reduce) {
#splash-loader .wave-group,
#splash-loader .wave-path {
animation: none;
}
#splash-loader .wave-group {
transform: translateY(-80px);
}
}
</style>
<link rel="manifest" href="manifest.json" />
</head>

View File

@@ -44,7 +44,7 @@
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:unit": "nx run test",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -15,10 +15,10 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent}]");
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@custom-variant touch (@media (hover: none));

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 18V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H18M14 17V18.6667L18 16L16.6579 15.1053M12 13.9666C12 12.9057 11.5786 11.8883 10.8284 11.1381C10.0783 10.388 9.06087 9.96655 8 9.96655C6.93913 9.96655 5.92172 10.388 5.17157 11.1381C4.42143 11.8883 4 12.9057 4 13.9666M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M9.41415 8.04751C10.1952 7.26647 10.1952 6.00014 9.41415 5.21909C8.6331 4.43804 7.36677 4.43804 6.58572 5.21909C5.80467 6.00014 5.80467 7.26647 6.58572 8.04751C7.36677 8.82856 8.6331 8.82856 9.41415 8.04751ZM3.33333 1.96655H12.6667C13.403 1.96655 14 2.56351 14 3.29989V12.6332C14 13.3696 13.403 13.9666 12.6667 13.9666H3.33333C2.59695 13.9666 2 13.3696 2 12.6332V3.29989C2 2.56351 2.59695 1.96655 3.33333 1.96655Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 18L19.9427 15.9426C19.6926 15.6927 19.3536 15.5522 19 15.5522C18.6464 15.5522 18.3074 15.6927 18.0573 15.9426L12 22M10 17V20.6667C10 21.403 10.597 22 11.3333 22H20.6667C21.403 22 22 21.403 22 20.6667V11.3333C22 10.597 21.403 10 20.6667 10H17M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H20.6667C21.403 10 22 10.597 22 11.3333V20.6667C22 21.403 21.403 22 20.6667 22H11.3333C10.597 22 10 21.403 10 20.6667V17M14 15.5V18.6667L18 16L15 14M2.29912 4.63977C2.11384 4.8332 2 5.09561 2 5.38461V12.9231C2 13.5178 2.48215 14 3.07692 14H10.6154C10.8974 14 11.1541 13.8916 11.3461 13.7141M2.29912 4.63977C2.49515 4.43512 2.77116 4.30769 3.07692 4.30769H10.6154C10.9061 4.30769 11.1524 4.46662 11.3461 4.65384M2.29912 4.63977L4.59359 2.34615C4.79033 2.13329 5.07191 2 5.38463 2H12.9231C13.2201 2 13.4891 2.12025 13.6839 2.31473M11.3461 13.7141C11.559 13.5174 11.6923 13.2358 11.6923 12.9231V5.38461C11.6923 5.08055 11.5488 4.84967 11.3461 4.65384M11.3461 13.7141L13.6538 11.4064C13.8667 11.2097 14 10.9281 14 10.6154V3.07692C14 2.77918 13.8792 2.50967 13.6839 2.31473M11.3461 4.65384L13.6839 2.31473M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ElevenLabs</title><path d="M5 0h5v24H5V0zM14 0h5v24h-5V0z"></path></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 20.1046 3.79594 21 4.77778 21H11V3H4.77778C3.79594 3 3 3.89543 3 5M3 19V5M3 19C3 19.5304 3.21071 20.0391 3.58579 20.4142C3.96086 20.7893 4.46957 21 5 21M3 5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3M11 1L11 23M21 15L17.9 11.9C17.5237 11.5312 17.017 11.3258 16.4901 11.3284C15.9632 11.331 15.4586 11.5415 15.086 11.914L14 13M11 16L6 21M14 3H19.1538C20.1734 3 21 3.89543 21 5V19C21 20.1046 20.1734 21 19.1538 21H14V3ZM11 9C11 10.1046 10.1046 11 9 11C7.89543 11 7 10.1046 7 9C7 7.89543 7.89543 7 9 7C10.1046 7 11 7.89543 11 9Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

Some files were not shown because too many files have changed in this diff Show More