Compare commits

...

37 Commits

Author SHA1 Message Date
pythongosssss
994dcbb7f5 [backport cloud/1.42] fix: App mode - renaming widgets on subgraphs (#10245) (#10509)
Backport of #10245 to cloud/1.42.

Fixes app mode widget renaming on subgraphs by allowing `renameWidget`
to resolve promoted widget sources when the node is a subgraph, even
without explicit parents.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10509-backport-cloud-1-42-fix-App-mode-renaming-widgets-on-subgraphs-10245-32e6d73d3650816aa780e765e8605acc)
by [Unito](https://www.unito.io)
2026-03-25 14:00:24 -07:00
Comfy Org PR Bot
2faff7b7d2 [backport cloud/1.42] fix: handle clipboard errors in Copy Image and useCopyToClipboard (#10525)
Backport of #9299 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10525-backport-cloud-1-42-fix-handle-clipboard-errors-in-Copy-Image-and-useCopyToClipboard-32e6d73d3650810280fcd2a56d8808f0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-25 19:28:01 +00:00
Comfy Org PR Bot
25204af108 [backport cloud/1.42] fix: prune stale proxyWidgets referencing nodes removed by nested subgraph packing (#10521)
Backport of #10390 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10521-backport-cloud-1-42-fix-prune-stale-proxyWidgets-referencing-nodes-removed-by-nested--32e6d73d365081c0a6c0d41b45c67657)
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:44 +00:00
Comfy Org PR Bot
7c54627215 [backport cloud/1.42] 🧙 feat: wire ComfyHub publish wizard with profile gate, asset upload, and submission (#10492)
Backport of #10128 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10492-backport-cloud-1-42-feat-wire-ComfyHub-publish-wizard-with-profile-gate-asset-upl-32e6d73d365081b39805fe808749d24f)
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-24 18:34:01 -07:00
Arthur R Longbottom
0a65309dd2 [backport cloud/1.42] fix: subgraph promoted widget input label rename (#10421)
Backport of #10195 to cloud/1.42.

Cherry-picked from the core/1.42 backport commits (identical codebase).
Test fix included: `renameWidget` test passes `parents` explicitly since
1.42 uses `parents?.length` (not `node.isSubgraphNode()` fallback). Also
includes negative test for subgraph node without parents.

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

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-24 17:23:16 -07:00
Comfy Org PR Bot
01b6f5258c [backport cloud/1.42] fix: manager progress toast and install button UX issues (#10426)
Backport of #10423 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10426-backport-cloud-1-42-fix-manager-progress-toast-and-install-button-UX-issues-32d6d73d365081c887d2fc01414a7c77)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-03-24 17:12:49 -07:00
Comfy Org PR Bot
60dc9797e8 [backport cloud/1.42] fix: prevent blueprint cache corruption on repeated placement (#10460)
Backport of #9897 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10460-backport-cloud-1-42-fix-prevent-blueprint-cache-corruption-on-repeated-placement-32d6d73d36508131aec3c65bd5d53c9e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-24 17:12:34 -07:00
Comfy Org PR Bot
c789897e36 [backport cloud/1.42] fix: restore Firebase getAdditionalUserInfo for sign-up telemetry OR logic (#10468)
Backport of #10453 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-24 13:26:29 -07:00
Comfy Org PR Bot
47876596b5 [backport cloud/1.42] fix: App mode - Move active output spinner/items outside scrollable area (#10451)
Backport of #10243 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10451-backport-cloud-1-42-fix-App-mode-Move-active-output-spinner-items-outside-scrollabl-32d6d73d365081689b98eb84868ce45d)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-24 09:01:09 -07:00
Comfy Org PR Bot
ffe067a9f8 [backport cloud/1.42] feat: App mode - add lightbox to view image in drop zone (#10449)
Backport of #9888 to `cloud/1.42`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 08:39:06 +00:00
Comfy Org PR Bot
97619f2f69 [backport cloud/1.42] fix: sync advanced inputs button color with node header (#10431)
Backport of #10427 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-24 16:10:01 +09:00
Comfy Org PR Bot
e44a1ad782 [backport cloud/1.42] fix: use UI flow context for sign_up vs login telemetry (#10400)
Backport of #10388 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10400-backport-cloud-1-42-fix-use-UI-flow-context-for-sign_up-vs-login-telemetry-32c6d73d36508122a027d12f4610a3e9)
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:42:02 -07:00
Comfy Org PR Bot
7c92f4bd01 [backport cloud/1.42] feat: enable Quiver AI icon for partner nodes (#10384)
Backport of #10366 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10384-backport-cloud-1-42-feat-enable-Quiver-AI-icon-for-partner-nodes-32b6d73d365081559013fac66f07bb03)
by [Unito](https://www.unito.io)

Co-authored-by: Yourz <crazilou@vip.qq.com>
2026-03-21 23:04:31 -07:00
Comfy Org PR Bot
5fc5948d75 [backport cloud/1.42] fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10376)
Backport of #10289 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-03-21 15:46:12 -07:00
Comfy Org PR Bot
c3e98d548c [backport cloud/1.42] Don't use reactives for app mode selections (#10352)
Backport of #10342 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10352-backport-cloud-1-42-Don-t-use-reactives-for-app-mode-selections-3296d73d3650816789f4d7a799926839)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-03-20 19:18:01 -07:00
Comfy Org PR Bot
4f715f3a23 [backport cloud/1.42] feat: App mode - allow resizing of textarea and image previews (#10347)
Backport of #9792 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-20 10:11:19 -07:00
Comfy Org PR Bot
812ef639ad [backport cloud/1.42] fix: make graph canvas toolbar visible on mobile (#10275)
Backport of #10168 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10275-backport-cloud-1-42-fix-make-graph-canvas-toolbar-visible-on-mobile-3276d73d365081c891e4ea0bd2bd1886)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-19 18:25:59 -07:00
Alexander Brown
e5fcca95a9 [backport cloud/1.42] test: subgraph integration contracts and expanded Playwright coverage (#10329)
Backport of #10123, #9967, and #9972 to `cloud/1.42`

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` (1 conflict, 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:25:35 -07:00
Comfy Org PR Bot
8de7795219 [backport cloud/1.42] fix: configure nested subgraph definitions in dependency order (#10318)
Backport of #10314 to `cloud/1.42`

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:15 -07:00
Comfy Org PR Bot
57a1d6cfa3 [backport cloud/1.42] fix: prevent nested SubgraphNode input slots from doubling on reload (#10285)
Backport of #10187 to `cloud/1.42`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10283-backport-cloud-1-42-fix-resync-slot-layouts-when-switching-between-app-mode-and-graph-3276d73d365081b8a329e48f54cc0cc9)
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:01:58 -07:00
Comfy Org PR Bot
25a353aa16 [backport cloud/1.42] fix: App mode - handle socket/response race when tracking jobs (#10268)
Backport of #10244 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 12:58:53 -07:00
Comfy Org PR Bot
9f904b1e44 [backport cloud/1.42] feat: App mode - update keybindings (#10251)
Backport of #9794 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-03-18 12:39:30 -07:00
Comfy Org PR Bot
55710dfbba [backport cloud/1.42] fix: resync vue node layout store after legacy normalization (#10264)
Backport of #10256 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-03-18 19:13:59 +00:00
Comfy Org PR Bot
ed96ffba93 [backport cloud/1.42] fix: track nodePreviewImages in usePromotedPreviews (#10200)
Backport of #10165 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10200-backport-cloud-1-42-fix-track-nodePreviewImages-in-usePromotedPreviews-3266d73d365081e2bc82f557e254d54a)
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:38:19 -07:00
Comfy Org PR Bot
32801ccc21 [backport cloud/1.42] fix: replace stale-request guard with single-flight coalescing in queueStore.update() (#10217)
Backport of #10203 to `cloud/1.42`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 13:52:01 -07:00
Comfy Org PR Bot
6fc9d1c0e3 [backport cloud/1.42] feat: migrate 13 priority events from Mixpanel-only to GA4 via GTM (#10183)
Backport of #9770 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10183-backport-cloud-1-42-feat-migrate-13-priority-events-from-Mixpanel-only-to-GA4-via-GTM-3266d73d3650812da265caae234f4b1b)
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 07:18:43 -07:00
Comfy Org PR Bot
e11d5548a1 [backport cloud/1.42] fix: resolve nodes in subgraphs for image copy/paste and display (#10185)
Backport of #10009 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10185-backport-cloud-1-42-fix-resolve-nodes-in-subgraphs-for-image-copy-paste-and-display-3266d73d365081b5a776c60758acfb81)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 14:14:04 +00:00
Comfy Org PR Bot
ccb3e33eb8 [backport cloud/1.42] feat: resolveVirtualOutput for cross-subgraph virtual nodes (eg. Set/Get) (#10182)
Backport of #10111 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: Jukka Seppänen <40791699+kijai@users.noreply.github.com>
2026-03-17 14:07:16 +00:00
Comfy Org PR Bot
ed40779cdc [backport cloud/1.42] feat: add linear interpolation type to CURVE widget (#10173)
Backport of #10118 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10173-backport-cloud-1-42-feat-add-linear-interpolation-type-to-CURVE-widget-3266d73d365081fc80b1edd8e33b7d68)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-17 04:14:30 -07:00
Comfy Org PR Bot
d809f51831 [backport cloud/1.42] feat: improve essentials tab blueprint support and display names (#10160)
Backport of #10113 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10160-backport-cloud-1-42-feat-improve-essentials-tab-blueprint-support-and-display-names-3266d73d365081e78530df79d35f73cf)
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:51:00 -07:00
Comfy Org PR Bot
a20258abd1 [backport cloud/1.42] fix: prune orphaned SubgraphNode inputs after configure (#10150)
Backport of #10020 to `cloud/1.42`

Automatically created by backport workflow.

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

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-17 01:24:12 -07:00
Comfy Org PR Bot
53458b5ada [backport cloud/1.42] fix: show webcam capture button in Vue renderer (#10144)
Backport of #9936 to `cloud/1.42`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10144-backport-cloud-1-42-fix-show-webcam-capture-button-in-Vue-renderer-3266d73d365081038006f3dd80dd6a00)
by [Unito](https://www.unito.io)

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

Automatically created by backport workflow.

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

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-03-16 06:46:51 -07:00
231 changed files with 13099 additions and 3539 deletions

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,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

@@ -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
@@ -217,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)

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

@@ -52,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'
},
@@ -83,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

@@ -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

@@ -764,13 +764,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')
@@ -829,6 +829,82 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
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)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

View File

@@ -24,6 +24,20 @@ test.describe(
)
})
test('@mobile graph canvas toolbar visible', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.nextFrame()
const minimapButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(minimapButton).toBeVisible()
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-graph-canvas-toolbar.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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,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('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', 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: initialWidgets.length,
firstWidgetType: 'button'
})
})
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,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

@@ -73,5 +73,59 @@ test.describe(
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')
@@ -545,6 +524,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
}) => {
@@ -702,6 +705,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
}) => {
@@ -724,15 +765,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')

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

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,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}]");
@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,quiver-ai}]");
/* 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,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

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="M21 14.9999L17.914 11.9139C17.5389 11.539 17.0303 11.3284 16.5 11.3284C15.9697 11.3284 15.4611 11.539 15.086 11.9139L12.6935 14.3064M14.5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V13M11 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 9ZM2.03125 18.6735C1.98958 18.5613 1.98958 18.4378 2.03125 18.3255C2.43708 17.3415 3.12595 16.5001 4.01054 15.9081C4.89512 15.3161 5.93558 15 7 15C8.06442 15 9.10488 15.3161 9.98946 15.9081C10.874 16.5001 11.5629 17.3415 11.9687 18.3255C12.0104 18.4378 12.0104 18.5613 11.9687 18.6735C11.5629 19.6575 10.874 20.4989 9.98946 21.0909C9.10488 21.683 8.06442 21.999 7 21.999C5.93558 21.999 4.89512 21.683 4.01054 21.0909C3.12595 20.4989 2.43708 19.6575 2.03125 18.6735ZM8.49992 18.4995C8.49992 19.3278 7.82838 19.9994 6.99999 19.9994C6.17161 19.9994 5.50007 19.3278 5.50007 18.4995C5.50007 17.6711 6.17161 16.9995 6.99999 16.9995C7.82838 16.9995 8.49992 17.6711 8.49992 18.4995Z" 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="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.5 13.7503L20.5 15.7503M11 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 9ZM21.5871 14.6562C21.8514 14.3919 22 14.0334 22 13.6596C22 13.2858 21.8516 12.9273 21.5873 12.6629C21.323 12.3986 20.9645 12.25 20.5907 12.25C20.2169 12.25 19.8584 12.3984 19.594 12.6627L12.921 19.3373C12.8049 19.453 12.719 19.5955 12.671 19.7523L12.0105 21.9283C11.9975 21.9715 11.9966 22.0175 12.0076 22.0612C12.0187 22.105 12.0414 22.1449 12.0734 22.1768C12.1053 22.2087 12.1453 22.2313 12.189 22.2423C12.2328 22.2533 12.2787 22.2523 12.322 22.2393L14.4985 21.5793C14.6551 21.5317 14.7976 21.4463 14.9135 21.3308L21.5871 14.6562Z" 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="M8.5 21H5M5 21C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9.5M5 21L14.086 11.914C14.4586 11.5415 14.9632 11.331 15.4901 11.3284M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M11 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 9ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.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.5 15H4.33333C3.59695 15 3 14.403 3 13.6667V4.33333C3 3.59695 3.59695 3 4.33333 3H13.6667C14.403 3 15 3.59695 15 4.33333V11L12.9427 8.94263C12.6926 8.69267 12.3536 8.55225 12 8.55225C11.6464 8.55225 11.3074 8.69267 11.0573 8.94263L5 15M18.3109 20.2074L12.9705 18.7508M15.4997 15.2586C14.5976 16.6137 13.5146 16.9887 12.208 17.2328C12.1646 17.2407 12.1241 17.2597 12.0904 17.2881C12.0567 17.3164 12.0309 17.3531 12.0157 17.3944C12.0004 17.4358 11.9962 17.4804 12.0035 17.5238C12.0107 17.5673 12.0291 17.6081 12.057 17.6423L15.7172 22.0841C15.7916 22.163 15.8896 22.2157 15.9964 22.2341C16.1033 22.2525 16.2133 22.2356 16.3098 22.1861C17.3673 21.4615 18.9999 19.6549 18.9999 18.7589M18 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V9M10 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V18M8.33333 7C8.33333 7.73638 7.73638 8.33333 7 8.33333C6.26362 8.33333 5.66667 7.73638 5.66667 7C5.66667 6.26362 6.26362 5.66667 7 5.66667C7.73638 5.66667 8.33333 6.26362 8.33333 7ZM20.188 12.5694C20.2866 12.4709 20.4036 12.3927 20.5324 12.3393C20.6611 12.286 20.7992 12.2585 20.9386 12.2585C21.078 12.2585 21.216 12.286 21.3448 12.3393C21.4735 12.3927 21.5905 12.4709 21.6891 12.5694C21.7877 12.668 21.8659 12.785 21.9192 12.9138C21.9725 13.0426 22 13.1806 22 13.32C22 13.4594 21.9725 13.5974 21.9192 13.7262C21.8659 13.855 21.7877 13.972 21.6891 14.0705L19.68 16.0802C19.6331 16.1271 19.6068 16.1906 19.6068 16.2569C19.6068 16.3232 19.6331 16.3868 19.68 16.4337L20.152 16.9057C20.378 17.1317 20.5049 17.4382 20.5049 17.7578C20.5049 18.0774 20.378 18.3838 20.152 18.6098L19.68 19.0819C19.6331 19.1287 19.5695 19.1551 19.5032 19.1551C19.4369 19.1551 19.3733 19.1287 19.3265 19.0819L15.1767 14.9326C15.1298 14.8857 15.1035 14.8221 15.1035 14.7558C15.1035 14.6895 15.1298 14.626 15.1767 14.5791L15.6487 14.107C15.8747 13.8811 16.1812 13.7541 16.5008 13.7541C16.8203 13.7541 17.1268 13.8811 17.3528 14.107L17.8249 14.5791C17.8717 14.6259 17.9353 14.6523 18.0016 14.6523C18.0679 14.6523 18.1315 14.6259 18.1784 14.5791L20.188 12.5694Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.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="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 10H17M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.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.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.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 17.9999V20.6666C10 21.403 10.597 21.9999 11.3333 21.9999H20.6667C21.403 21.9999 22 21.403 22 20.6666V11.3333C22 10.5969 21.403 9.99994 20.6667 9.99994H18M14 16.9999V18.6666L18 15.9999L16.6579 15.1052M14 9.96651L11.9427 7.90918C11.6926 7.65922 11.3536 7.5188 11 7.5188C10.6464 7.5188 10.3074 7.65922 10.0573 7.90918L4 13.9665M5 21.9999L7 19.9999M7 19.9999H4C3.46957 19.9999 2.96086 19.7892 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 17.9999V16.9999M7 19.9999L5 17.9999M3.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.96655ZM7.33333 5.96655C7.33333 6.70293 6.73638 7.29989 6 7.29989C5.26362 7.29989 4.66667 6.70293 4.66667 5.96655C4.66667 5.23017 5.26362 4.63322 6 4.63322C6.73638 4.63322 7.33333 5.23017 7.33333 5.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 10H17M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.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.4 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.1053M8 4.63322L8.87155 4.08134C8.95314 4.02967 9.05313 4.01593 9.14563 4.04367L10 4.29989M8 4.63322L7.12845 4.08134C7.04686 4.02967 6.94687 4.01593 6.85437 4.04367L6 4.29989M8 4.63322V6.63322M8 8.96655L6.74997 9.90408C6.69573 9.94476 6.65518 10.001 6.63374 10.0653L6 11.9666M8 8.96655L9.25003 9.90408C9.30427 9.94476 9.34482 10.001 9.36626 10.0653L10 11.9666M8 8.96655V6.63322M8 6.63322H9.86193C9.95033 6.63322 10.0351 6.66834 10.0976 6.73085L10.9489 7.58216C10.9826 7.61581 11.0086 7.65627 11.0254 7.70083L11.5 8.96655M8 6.63322H6.13807C6.04967 6.63322 5.96488 6.66834 5.90237 6.73085L5.05205 7.58117C5.01776 7.61546 4.99137 7.65681 4.97471 7.70235L4.5 9M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V17M7 20L5 18M3.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.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
</svg>

After

Width:  |  Height:  |  Size: 534 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -1,5 +0,0 @@
<svg width="49" height="24" viewBox="0 0 49 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.5243 11.7208C26.7584 11.955 26.7583 12.3351 26.5243 12.5694L23.9794 15.1153C23.7452 15.3495 23.3651 15.3493 23.1308 15.1153C22.8965 14.881 22.8965 14.501 23.1308 14.2667L24.6474 12.7501L17.0995 12.7501C16.7683 12.7499 16.4999 12.4807 16.4999 12.1495C16.5001 11.8184 16.7685 11.5501 17.0995 11.5499L24.6571 11.5499L23.1308 10.0235C22.8965 9.7892 22.8965 9.40919 23.1308 9.17489C23.3651 8.94127 23.7453 8.94083 23.9794 9.17489L26.5243 11.7208Z" fill="#8A8A8A"/>
<path d="M4.50779 3.61371C4.6735 3.55748 4.85537 3.56703 5.0156 3.64203L6.14353 4.16938L6.9199 3.685L6.99509 3.64496C7.17632 3.56122 7.38691 3.56099 7.57029 3.64692L8.95798 4.29633C9.2306 4.42419 9.34839 4.74924 9.22068 5.02192C9.09287 5.29443 8.76771 5.4121 8.49509 5.28461L7.30857 4.72797L6.5449 5.20551V6.97211H9.32127C9.55299 6.97221 9.76687 7.08954 9.89158 7.27973L9.93943 7.36567L11.2588 10.1918C11.2714 10.2189 11.282 10.2474 11.291 10.2758L11.3125 10.3627L11.9902 14.2407C12.042 14.5373 11.8435 14.8206 11.5469 14.8725C11.2505 14.924 10.9681 14.7254 10.916 14.4291L10.247 10.6049L9.06052 8.06293H6.5449V11.6079C6.54489 11.6553 6.53579 11.7007 6.52439 11.7446H7.03318C7.26977 11.7446 7.48688 11.8665 7.61033 12.0629L7.6572 12.1518L8.89548 14.9711C8.9258 15.0402 8.94492 15.1138 8.95115 15.1889L9.26951 19.0629C9.29388 19.3627 9.07125 19.6259 8.77146 19.6508C8.47137 19.6755 8.20837 19.4519 8.18357 19.1518L7.86912 15.3471L6.7656 12.8344H5.52048L4.12693 15.3715L3.81541 19.1518C3.79062 19.4519 3.52762 19.6755 3.22752 19.6508C2.92759 19.626 2.70508 19.3628 2.72947 19.0629L3.04685 15.1957L3.05662 15.1245C3.06994 15.0542 3.09436 14.9862 3.12888 14.9233L4.68064 12.0981L4.73045 12.02C4.85779 11.8482 5.05991 11.7448 5.27732 11.7446H5.47361C5.46224 11.7007 5.45409 11.6553 5.45408 11.6079V8.06293H3.24412L1.74509 10.6323L1.08103 14.1127C1.02439 14.4084 0.738116 14.6018 0.44236 14.5454C0.146978 14.4885 -0.0465728 14.2032 0.00974259 13.9077L0.687477 10.3598L0.718727 10.2485C0.732186 10.2126 0.748186 10.1772 0.767555 10.144L2.42088 7.31C2.54303 7.10073 2.76744 6.97221 3.00974 6.97211H5.45408V5.05121L4.72654 4.71039L3.50388 5.28461C3.23123 5.41225 2.90614 5.2945 2.7783 5.02192C2.65052 4.74922 2.76835 4.4242 3.04099 4.29633L4.43748 3.64203L4.50779 3.61371Z" fill="#8A8A8A"/>
<path d="M44.7027 5C46.7971 5 48.5097 6.71671 48.5097 8.84956V14.3804C48.5097 16.5133 46.7971 18.23 44.7027 18.23H35.3067C33.2122 18.23 31.4997 16.5133 31.4997 14.3804V8.84956C31.4997 6.71671 33.2122 5 35.3067 5H44.7027ZM35.3067 6.37812C33.9312 6.37812 32.8496 7.48086 32.8496 8.84956V14.3804C32.8496 15.7491 33.9312 16.8519 35.3067 16.8519H44.7027C46.0781 16.8519 47.1597 15.7491 47.1597 14.3804V8.84956C47.1597 7.48086 46.0781 6.37812 44.7027 6.37812H35.3067ZM38.0595 8.12949C38.1749 8.13322 38.2881 8.16994 38.3859 8.23544L42.6927 11.0009C42.9191 11.1419 43.0022 11.403 43.0022 11.615C43.0022 11.8269 42.9187 12.0878 42.6924 12.2288L38.3862 14.9946C38.1806 15.132 37.9132 15.1341 37.706 15.002L37.7043 15.0009C37.4997 14.8683 37.3782 14.6208 37.3845 14.3727V8.84956C37.3797 8.49772 37.6466 8.14891 38.0086 8.13007L38.0595 8.12949ZM38.7038 13.1249L41.0623 11.6144L38.7038 10.0996V13.1249ZM42.675 11.8255C42.6603 11.8574 42.6421 11.887 42.6201 11.913L42.6503 11.8717C42.6595 11.8571 42.6677 11.8414 42.675 11.8255Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 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 14.5V20.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 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 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="M10 14.5V20.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 10H17.5M11.3333 2H2M14 6H2M10.0667 9.93327H2M5 22L7 20M7 20H4C3.46957 20 2.96086 19.7893 2.58579 19.4142C2.21071 19.0391 2 18.5304 2 18V14M7 20L5 18M14 13.3333L18 16L14 18.6667V13.3333Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,28 @@
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { describe, expect, it } from 'vitest'
/**
* Regression test: the graph-canvas-panel SplitterPanel must not clip
* absolutely-positioned children (like GraphCanvasMenu).
*
* PrimeVue applies `overflow: hidden` to all SplitterPanels by default.
* Without an explicit `overflow-visible` override, the bottom-right canvas
* toolbar becomes invisible on mobile viewports where the panel's bounding
* box is smaller than the full canvas area.
*
* @see https://www.notion.so/Bug-Graph-canvas-toolbar-not-visible-on-mobile-3246d73d36508144ae00f10065c42fac
*/
describe('LiteGraphCanvasSplitterOverlay', () => {
it('graph-canvas-panel has overflow-visible to prevent clipping toolbar on mobile', () => {
const filePath = resolve(__dirname, 'LiteGraphCanvasSplitterOverlay.vue')
const source = readFileSync(filePath, 'utf-8')
// The SplitterPanel wrapping graph-canvas-panel must include overflow-visible
// to override PrimeVue's default overflow:hidden on .p-splitterpanel.
// Without this, GraphCanvasMenu (absolute right-0 bottom-0) gets clipped on mobile.
expect(source).toMatch(
/class="[^"]*graph-canvas-panel[^"]*overflow-visible/
)
})
})

View File

@@ -72,7 +72,7 @@
state-storage="local"
@resizestart="onResizestart"
>
<SplitterPanel class="graph-canvas-panel relative">
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
<slot name="graph-canvas-panel" />
</SplitterPanel>
<SplitterPanel

View File

@@ -10,6 +10,8 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -17,6 +19,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -38,6 +41,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
@@ -97,21 +101,27 @@ const mappedSelections = computed((): WidgetEntry[] => {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
}
@@ -181,7 +191,11 @@ function nodeToNodeData(node: LGraphNode) {
]"
>
<template #button>
<Button variant="textonly" size="icon">
<Button
variant="textonly"
size="icon"
data-testid="widget-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
@@ -201,7 +215,7 @@ function nodeToNodeData(node: LGraphNode) {
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"

View File

@@ -63,7 +63,7 @@ const entries = computed(() => {
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<Button variant="muted-textonly" data-testid="widget-actions-menu">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -0,0 +1,61 @@
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ImageLightbox from './ImageLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function findCloseButton() {
const el = document.body.querySelector('[aria-label="g.close"]')
return el ? new DOMWrapper(el) : null
}
describe(ImageLightbox, () => {
let wrapper: VueWrapper
afterEach(() => {
wrapper.unmount()
})
function mountComponent(props: { src: string; alt?: string }, open = true) {
wrapper = mount(ImageLightbox, {
global: { plugins: [i18n] },
props: { ...props, modelValue: open },
attachTo: document.body
})
return wrapper
}
it('renders the image with correct src and alt when open', async () => {
mountComponent({ src: '/test.png', alt: 'Test image' })
await flushPromises()
const img = document.body.querySelector('img')
expect(img).toBeTruthy()
expect(img?.getAttribute('src')).toBe('/test.png')
expect(img?.getAttribute('alt')).toBe('Test image')
})
it('does not render dialog content when closed', async () => {
mountComponent({ src: '/test.png' }, false)
await flushPromises()
expect(document.body.querySelector('img')).toBeNull()
})
it('emits update:modelValue false when close button is clicked', async () => {
mountComponent({ src: '/test.png' })
await flushPromises()
const closeButton = findCloseButton()
expect(closeButton).toBeTruthy()
await closeButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
})

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const open = defineModel<boolean>({ default: false })
const { src, alt = '' } = defineProps<{
src: string
alt?: string
}>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-30 bg-black/60 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed top-1/2 left-1/2 z-1700 -translate-1/2 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-50"
@escape-key-down="open = false"
>
<VisuallyHidden>
<DialogTitle>{{ alt || t('g.imageLightbox') }}</DialogTitle>
<DialogDescription v-if="alt">{{ alt }}</DialogDescription>
</VisuallyHidden>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
class="absolute -top-2 -right-2 z-10 translate-x-full text-white hover:text-white/80"
>
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<img
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -8,7 +8,7 @@
:get-children="
(item) => (item.children?.length ? item.children : undefined)
"
class="m-0 min-w-0 p-0 pb-6"
class="m-0 min-w-0 p-0 pb-2"
>
<TreeVirtualizer
v-slot="{ item }"

View File

@@ -12,6 +12,7 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -23,6 +24,7 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
@@ -43,6 +45,16 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
@@ -52,7 +64,14 @@ function toggleLinearMode() {
const tooltipPt = {
root: {
style: { transform: 'translateX(calc(50% - 16px))' }
style: {
transform: 'translateX(calc(50% - 16px))',
whiteSpace: 'nowrap',
maxWidth: 'none'
}
},
text: {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
@@ -68,9 +87,7 @@ const tooltipPt = {
>
<Button
v-tooltip.bottom="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt

View File

@@ -82,23 +82,25 @@
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { computed, toRef, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import type { CurveInterpolation, CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
disabled = false
disabled = false,
interpolation = 'monotone_cubic'
} = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
interpolation?: CurveInterpolation
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -109,7 +111,8 @@ const svgRef = useTemplateRef<SVGSVGElement>('svgRef')
const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
svgRef,
modelValue
modelValue,
interpolation: toRef(() => interpolation)
})
function onSvgPointerDown(e: PointerEvent) {

View File

@@ -1,9 +1,30 @@
<template>
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
<div class="flex flex-col gap-1">
<Select
v-if="!isDisabled"
:model-value="modelValue.interpolation"
@update:model-value="onInterpolationChange"
>
<SelectTrigger size="md">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="interp in CURVE_INTERPOLATIONS"
:key="interp"
:value="interp"
>
{{ $t(`curveWidget.${interp}`) }}
</SelectItem>
</SelectContent>
</Select>
<CurveEditor
:model-value="effectiveCurve.points"
:disabled="isDisabled"
:interpolation="effectiveCurve.interpolation"
@update:model-value="onPointsChange"
/>
</div>
</template>
<script setup lang="ts">
@@ -15,31 +36,53 @@ import {
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
import { isCurveData } from './curveUtils'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
const modelValue = defineModel<CurvePoint[]>({
default: () => [
[0, 0],
[1, 1]
]
const modelValue = defineModel<CurveData>({
default: () => ({
points: [
[0, 0],
[1, 1]
],
interpolation: 'monotone_cubic'
})
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
singleValueExtractor(isCurveData)
)
const effectivePoints = computed(() =>
const effectiveCurve = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
function onPointsChange(points: CurvePoint[]) {
modelValue.value = { ...modelValue.value, points }
}
function onInterpolationChange(value: unknown) {
if (typeof value !== 'string') return
modelValue.value = {
...modelValue.value,
interpolation: value as CurveInterpolation
}
}
</script>

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { CurvePoint } from './types'
import {
createLinearInterpolator,
createMonotoneInterpolator,
curvesToLUT,
histogramToPath
@@ -73,6 +74,64 @@ describe('createMonotoneInterpolator', () => {
})
})
describe('createLinearInterpolator', () => {
it('returns 0 for empty points', () => {
const interpolate = createLinearInterpolator([])
expect(interpolate(0.5)).toBe(0)
})
it('returns constant for single point', () => {
const interpolate = createLinearInterpolator([[0.5, 0.7]])
expect(interpolate(0)).toBe(0.7)
expect(interpolate(1)).toBe(0.7)
})
it('passes through control points exactly', () => {
const points: CurvePoint[] = [
[0, 0],
[0.5, 0.8],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.8, 10)
expect(interpolate(1)).toBe(1)
})
it('linearly interpolates between points', () => {
const points: CurvePoint[] = [
[0, 0],
[1, 1]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0.25)).toBeCloseTo(0.25, 10)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(0.75)).toBeCloseTo(0.75, 10)
})
it('clamps to endpoint values outside range', () => {
const points: CurvePoint[] = [
[0.2, 0.3],
[0.8, 0.9]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0.3)
expect(interpolate(1)).toBe(0.9)
})
it('handles unsorted input points', () => {
const points: CurvePoint[] = [
[1, 1],
[0, 0],
[0.5, 0.5]
]
const interpolate = createLinearInterpolator(points)
expect(interpolate(0)).toBe(0)
expect(interpolate(0.5)).toBeCloseTo(0.5, 10)
expect(interpolate(1)).toBe(1)
})
})
describe('curvesToLUT', () => {
it('returns a 256-entry Uint8Array', () => {
const lut = curvesToLUT([

View File

@@ -1,19 +1,70 @@
import type { CurvePoint } from './types'
import { CURVE_INTERPOLATIONS } from './types'
import type { CurveData, CurveInterpolation, CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
export function isCurveData(value: unknown): value is CurveData {
if (typeof value !== 'object' || value === null || Array.isArray(value))
return false
const v = value as Record<string, unknown>
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(v.points) &&
v.points.every(
(p: unknown) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
) &&
typeof v.interpolation === 'string' &&
CURVE_INTERPOLATIONS.includes(v.interpolation as CurveInterpolation)
)
}
/**
* Piecewise linear interpolation through sorted control points.
* Returns a function that evaluates y for any x in [0, 1].
*/
export function createLinearInterpolator(
points: CurvePoint[]
): (x: number) => number {
if (points.length === 0) return () => 0
if (points.length === 1) return () => points[0][1]
const sorted = [...points].sort((a, b) => a[0] - b[0])
const n = sorted.length
const xs = sorted.map((p) => p[0])
const ys = sorted.map((p) => p[1])
return (x: number): number => {
if (x <= xs[0]) return ys[0]
if (x >= xs[n - 1]) return ys[n - 1]
let lo = 0
let hi = n - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (xs[mid] <= x) lo = mid
else hi = mid
}
const dx = xs[hi] - xs[lo]
if (dx === 0) return ys[lo]
const t = (x - xs[lo]) / dx
return ys[lo] + t * (ys[hi] - ys[lo])
}
}
/**
* Factory that dispatches to the correct interpolator based on type.
*/
export function createInterpolator(
points: CurvePoint[],
interpolation: CurveInterpolation
): (x: number) => number {
return interpolation === 'linear'
? createLinearInterpolator(points)
: createMonotoneInterpolator(points)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points
@@ -120,9 +171,12 @@ export function histogramToPath(histogram: Uint32Array): string {
return parts.join(' ')
}
export function curvesToLUT(points: CurvePoint[]): Uint8Array {
export function curvesToLUT(
points: CurvePoint[],
interpolation: CurveInterpolation = 'monotone_cubic'
): Uint8Array {
const lut = new Uint8Array(256)
const interpolate = createMonotoneInterpolator(points)
const interpolate = createInterpolator(points, interpolation)
for (let i = 0; i < 256; i++) {
const x = i / 255

View File

@@ -1 +1,10 @@
export type CurvePoint = [x: number, y: number]
export const CURVE_INTERPOLATIONS = ['monotone_cubic', 'linear'] as const
export type CurveInterpolation = (typeof CURVE_INTERPOLATIONS)[number]
export interface CurveData {
points: CurvePoint[]
interpolation: CurveInterpolation
}

View File

@@ -183,13 +183,13 @@ const toggleState = () => {
}
const signInWithGoogle = async () => {
if (await authActions.signInWithGoogle()) {
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
onSuccess()
}
}
const signInWithGithub = async () => {
if (await authActions.signInWithGithub()) {
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
onSuccess()
}
}

View File

@@ -164,9 +164,11 @@ import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { UnauthorizedError } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -207,6 +209,7 @@ const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const { linearMode } = storeToRefs(canvasStore)
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const toastStore = useToastStore()
@@ -279,6 +282,22 @@ watch(
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
watch(
() => linearMode.value,
(isLinearMode) => {
if (!shouldRenderVueNodes.value) return
if (isLinearMode) {
layoutStore.clearAllSlotLayouts()
} else {
// App mode hides the graph canvas with `display: none`, so slot connectors
// need a fresh DOM measurement pass before links can render correctly.
requestSlotLayoutSyncForAllNodes()
}
layoutStore.setPendingSlotSync(true)
}
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return

View File

@@ -80,7 +80,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { app } from '@/scripts/app'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -101,7 +101,7 @@ if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -78,19 +79,22 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
String(widgetNode.id),
widget.name
)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})
})
}

View File

@@ -14,6 +14,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -84,15 +88,27 @@ const widgetsList = computed((): NodeWidgetsList => {
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const { interiorNodeId, widgetName } of entries) {
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
String(w.sourceNodeId) === interiorNodeId &&
w.sourceWidgetName === widgetName
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === widgetName
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
@@ -113,12 +129,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(interiorNode.id),
widget.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
})
)
})

View File

@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
@@ -93,8 +95,11 @@ describe('WidgetActions', () => {
function createMockNode(): LGraphNode {
return {
id: 1,
type: 'TestNode'
} as LGraphNode
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
} as unknown as LGraphNode
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
@@ -206,4 +211,66 @@ describe('WidgetActions', () => {
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = {
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
} as unknown as SubgraphNode
const node = {
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
} as unknown as LGraphNode
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const wrapper = mount(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,10 +5,11 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
demoteWidget,
getSourceNodeId,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -73,26 +76,29 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return
}
const disambiguatingSourceNodeId = getSourceNodeId(widget)
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
}
canvasStore.canvas?.setDirty(true, true)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)
}
canvasStore.canvas?.setDirty(true, true)
}
function handleShowInput() {
if (!parents?.length) return
promoteWidget(node, widget, parents)
canvasStore.canvas?.setDirty(true, true)
}
function handleToggleFavorite() {

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isRecommendedWidget,
promoteWidget,
@@ -49,19 +51,29 @@ const activeWidgets = computed<WidgetItem[]>({
if (!node) return []
return promotionEntries.value.flatMap(
({ interiorNodeId, widgetName }): WidgetItem[] => {
if (interiorNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === widgetName)
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find(
(w) => w.name === widgetName
)
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
@@ -76,11 +88,16 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
interiorNodeId: String(n.id),
widgetName: getWidgetName(w)
}))
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
)
refreshPromotedWidgetRendering()
}
})
@@ -103,12 +120,11 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
if (!node) return []
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(n.id),
w.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
})
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -137,8 +153,20 @@ const filteredActive = computed<WidgetItem[]>(() => {
)
})
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
node.computeSize(node.size)
node.setDirtyCanvas(true, true)
canvasStore.canvas?.setDirty(true, true)
}
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
@@ -147,49 +175,26 @@ function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
promotionStore.demote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
getWidgetName(widget)
)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
promotionStore.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
}
function showAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredCandidates.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of filteredCandidates.value) {
promote(item)
}
}
function hideAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredActive.value) {
if (String(n.id) === '-1') continue
promotionStore.demote(
node.rootGraph.id,
node.id,
String(n.id),
getWidgetName(w)
)
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
demote(item)
}
}
function showRecommended() {
const node = activeNode.value
if (!node) return
for (const [n, w] of recommendedWidgets.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of recommendedWidgets.value) {
promote(item)
}
}

View File

@@ -167,7 +167,10 @@ import {
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import {
resolveBlueprintSuffix,
resolveEssentialsDisplayName
} from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
@@ -371,11 +374,38 @@ const essentialSections = computed(() => {
)
})
function disambiguateBlueprintLabels(
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
if (!root.children) return root
return {
...root,
children: root.children.map((folder) => {
if (folder.type !== 'folder' || !folder.children) return folder
const labelCounts = new Map<string, number>()
for (const node of folder.children) {
if (node.label)
labelCounts.set(node.label, (labelCounts.get(node.label) ?? 0) + 1)
}
return {
...folder,
children: folder.children.map((node) => {
if ((labelCounts.get(node.label ?? '') ?? 0) <= 1) return node
const suffix = resolveBlueprintSuffix(node.data?.name ?? '')
if (!suffix) return node
return { ...node, label: `${node.label} (${suffix})` }
})
}
})
}
}
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
const root = section
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
: fillNodeInfo({ key: 'root', label: '', children: [] })
return disambiguateBlueprintLabels(root)
})
function flattenRenderedLeaves(

View File

@@ -39,6 +39,7 @@ import { computed, inject } from 'vue'
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { resolveBlueprintIcon } from '@/constants/essentialsDisplayNames'
import { ESSENTIALS_ICON_OVERRIDES } from '@/constants/essentialsNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -73,6 +74,10 @@ const nodeIcon = computed(() => {
const nodeName = node.data?.name
if (nodeName && nodeName in ESSENTIALS_ICON_OVERRIDES)
return ESSENTIALS_ICON_OVERRIDES[nodeName]
if (nodeName) {
const blueprintIcon = resolveBlueprintIcon(nodeName)
if (blueprintIcon) return blueprintIcon
}
const iconName = nodeName ? kebabCase(nodeName) : 'node'
return `icon-[comfy--${iconName}]`
})

View File

@@ -140,13 +140,19 @@ export const useFirebaseAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGoogle()
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
return await authStore.loginWithGithub()
}, reportError)
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {

View File

@@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -413,12 +415,10 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
'10',
'$$canvas-image-preview'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
@@ -500,12 +500,10 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
@@ -523,6 +521,70 @@ describe('Nested promoted widget mapping', () => {
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
@@ -91,6 +92,10 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -224,19 +229,21 @@ function safeWidgetMapper(
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
@@ -250,7 +257,8 @@ function safeWidgetMapper(
const displayName = promotedInputName ?? widget.name
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
@@ -297,7 +305,8 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
@@ -310,7 +319,11 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
@@ -343,7 +356,8 @@ function safeWidgetMapper(
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(
@@ -794,6 +808,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
@@ -46,6 +46,10 @@ function createSubgraphNode(): SubgraphNode {
return node
}
function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,4 +91,16 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
mocks.selectedItems = [createRegularNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -86,6 +86,24 @@ function useVueNodeLifecycleIndividual() {
() => !shouldRenderVueNodes.value,
() => {
disposeNodeManagerAndSyncs()
// Force arrange() on all nodes so input.pos is computed before
// the first legacy drawConnections frame (which may run before
// drawNode on the foreground canvas).
const graph = comfyApp.canvas?.graph
if (!graph) {
comfyApp.canvas?.setDirty(true, true)
return
}
for (const node of graph._nodes) {
if (node.flags.collapsed) continue
try {
node.arrange()
} catch {
/* skip nodes not fully initialized */
}
}
comfyApp.canvas?.setDirty(true, true)
}
)

View File

@@ -5,6 +5,19 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export function extractWidgetStringValue(value: unknown): string | undefined {
if (typeof value === 'string') return value
if (
value &&
typeof value === 'object' &&
'filename' in value &&
typeof value.filename === 'string'
)
return value.filename
return undefined
}
// Private image utility functions
interface ImageLayerFilenames {
@@ -84,62 +97,23 @@ export function useMaskEditorLoader() {
let nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
const imageWidget = node.widgets?.find((w) => w.name === 'image')
const widgetFilename = imageWidget
? extractWidgetStringValue(imageWidget.value)
: undefined
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
if (widgetFilename && !widgetFilename.startsWith('$')) {
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
const parsed = parseImageWidgetValue(widgetFilename)
nodeImageRef = {
filename: parsed.filename,
type: parsed.type || 'input',
subfolder: parsed.subfolder || undefined
}
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
}
const fileToQuery = widgetFilename || nodeImageRef.filename

View File

@@ -0,0 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}

View File

@@ -1,16 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'PreviewImage',
'SaveImage',
'GLSLShader'
])
export function supportsVirtualCanvasImagePreview(node: LGraphNode): boolean {
return CANVAS_IMAGE_PREVIEW_NODE_TYPES.has(node.type)
}
import { CANVAS_IMAGE_PREVIEW_WIDGET } from '@/composables/node/canvasImagePreviewTypes'
/**
* Composable for handling canvas image previews in nodes

View File

@@ -16,7 +16,7 @@ import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'getNodeImageUrls'
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
@@ -35,6 +35,7 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
@@ -71,12 +72,24 @@ function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -99,8 +112,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -113,19 +125,18 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
@@ -138,14 +149,11 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.webm'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('video')
@@ -157,14 +165,11 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([
'/view?filename=output.mp3'
])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
@@ -183,24 +188,20 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'20',
'$$canvas-image-preview'
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -208,14 +209,63 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -227,8 +277,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'99',
'$$canvas-image-preview'
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -241,19 +290,17 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(mockUrls)
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)

View File

@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
}
@@ -30,25 +30,29 @@ export function usePromotedPreviews(
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
)
if (!pseudoEntries.length) return []
const previews: PromotedPreview[] = []
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
// Read from the reactive nodeOutputs ref to establish Vue
// dependency tracking. getNodeImageUrls reads from the
// non-reactive app.nodeOutputs, so without this access the
// computed would never re-evaluate when outputs change.
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
entry.sourceNodeId
)
const _reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
if (!_reactiveOutputs?.images?.length) continue
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue
@@ -61,8 +65,8 @@ export function usePromotedPreviews(
: 'image'
previews.push({
interiorNodeId: entry.interiorNodeId,
widgetName: entry.widgetName,
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})

View File

@@ -0,0 +1,94 @@
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCopy = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@vueuse/core', () => ({
useClipboard: vi.fn(() => ({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true)
}))
}))
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import { useClipboard } from '@vueuse/core'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
describe('useCopyToClipboard', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => true),
text: ref('')
})
})
it('shows success toast when modern clipboard succeeds', async () => {
mockCopy.mockResolvedValue(undefined)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).toHaveBeenCalledWith('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('falls back to legacy when modern clipboard fails', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('shows error toast when both modern and legacy fail', async () => {
mockCopy.mockRejectedValue(new Error('Not allowed'))
document.execCommand = vi.fn(() => false)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('falls through to legacy when isSupported is false', async () => {
vi.mocked(useClipboard).mockReturnValue({
copy: mockCopy,
copied: ref(false),
isSupported: computed(() => false),
text: ref('')
})
document.execCommand = vi.fn(() => true)
const { copyToClipboard } = useCopyToClipboard()
await copyToClipboard('hello')
expect(mockCopy).not.toHaveBeenCalled()
expect(document.execCommand).toHaveBeenCalledWith('copy')
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
})

View File

@@ -3,34 +3,60 @@ import { useToast } from 'primevue/usetoast'
import { t } from '@/i18n'
function legacyCopy(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.setAttribute('readonly', '')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} finally {
textarea.remove()
}
}
export function useCopyToClipboard() {
const { copy, copied } = useClipboard({ legacy: true })
const { copy, isSupported } = useClipboard()
const toast = useToast()
async function copyToClipboard(text: string) {
let success = false
try {
await copy(text)
if (copied.value) {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
})
} else {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
if (isSupported.value) {
await copy(text)
success = true
}
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
})
// Modern clipboard API failed, fall through to legacy
}
if (!success) {
try {
success = legacyCopy(text)
} catch {
// Legacy also failed
}
}
toast.add(
success
? {
severity: 'success',
summary: t('g.success'),
detail: t('clipboard.successMessage'),
life: 3000
}
: {
severity: 'error',
summary: t('g.error'),
detail: t('clipboard.errorMessage')
}
)
}
return {

View File

@@ -1,25 +1,37 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import type { Ref } from 'vue'
import { createMonotoneInterpolator } from '@/components/curve/curveUtils'
import type { CurvePoint } from '@/components/curve/types'
import { createInterpolator } from '@/components/curve/curveUtils'
import type { CurveInterpolation, CurvePoint } from '@/components/curve/types'
interface UseCurveEditorOptions {
svgRef: Ref<SVGSVGElement | null>
modelValue: Ref<CurvePoint[]>
interpolation: Ref<CurveInterpolation>
}
export function useCurveEditor({ svgRef, modelValue }: UseCurveEditorOptions) {
export function useCurveEditor({
svgRef,
modelValue,
interpolation
}: UseCurveEditorOptions) {
const dragIndex = ref(-1)
let cleanupDrag: (() => void) | null = null
const curvePath = computed(() => {
const points = modelValue.value
if (points.length < 2) return ''
const sorted = [...points].sort((a, b) => a[0] - b[0])
const interpolate = createMonotoneInterpolator(points)
const xMin = points[0][0]
const xMax = points[points.length - 1][0]
if (interpolation.value === 'linear') {
return sorted
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0]},${1 - p[1]}`)
.join('')
}
const interpolate = createInterpolator(sorted, interpolation.value)
const xMin = sorted[0][0]
const xMax = sorted[sorted.length - 1][0]
const segments = 128
const range = xMax - xMin
const parts: string[] = []

View File

@@ -4,8 +4,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'
type ResizeDirection =
| 'top'
@@ -558,10 +558,7 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const initialize = () => {
if (nodeId != null) {
node.value =
app.canvas?.graph?.getNodeById(nodeId) ||
app.rootGraph?.getNodeById(nodeId) ||
null
node.value = resolveNode(nodeId) ?? null
}
updateImageUrl()

View File

@@ -35,7 +35,8 @@ vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
removeEventListener: vi.fn(),
getServerFeature: vi.fn(() => false)
}
}))

View File

@@ -5,6 +5,10 @@ import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
isAssetPreviewSupported,
persistThumbnail
} from '@/platform/assets/utils/assetPreviewUtil'
import type {
AnimationItem,
CameraConfig,
@@ -514,19 +518,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
if (load3d) {
if (load3d && isAssetPreviewSupported()) {
const node = nodeRef.value
const modelWidget = node?.widgets?.find(
(w) => w.name === 'model_file' || w.name === 'image'
)
const value = modelWidget?.value
if (typeof value === 'string') {
void Load3dUtils.generateThumbnailIfNeeded(
load3d,
value,
isPreview.value ? 'output' : 'input'
)
if (typeof value === 'string' && value) {
const filename = value.trim().replace(/\s*\[output\]$/, '')
const modelName = Load3dUtils.splitFilePath(filename)[1]
load3d
.captureThumbnail(256, 256)
.then((dataUrl) => fetch(dataUrl).then((r) => r.blob()))
.then((blob) => persistThumbnail(modelName, blob))
.catch(() => {})
}
}
},

View File

@@ -1,3 +1,4 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -59,22 +60,26 @@ const EXACT_NAME_MAP: Record<string, string> = {
* (after removing the SubgraphBlueprint. prefix) starts with the key.
* Ordered longest-first so more specific prefixes match before shorter ones.
*/
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
const BLUEPRINT_PREFIX_MAP: [
prefix: string,
displayNameKey: string,
category: EssentialsCategory
][] = [
// Image Generation
['image_inpainting_', 'essentials.inpaintImage'],
['image_outpainting_', 'essentials.outpaintImage'],
['image_edit', 'essentials.imageToImage'],
['text_to_image', 'essentials.textToImage'],
['pose_to_image', 'essentials.poseToImage'],
['canny_to_image', 'essentials.cannyToImage'],
['depth_to_image', 'essentials.depthToImage'],
['image_inpainting_', 'essentials.inpaintImage', 'image generation'],
['image_outpainting_', 'essentials.outpaintImage', 'image generation'],
['image_edit', 'essentials.imageToImage', 'image generation'],
['text_to_image', 'essentials.textToImage', 'image generation'],
['pose_to_image', 'essentials.poseToImage', 'image generation'],
['canny_to_image', 'essentials.cannyToImage', 'image generation'],
['depth_to_image', 'essentials.depthToImage', 'image generation'],
// Video Generation
['text_to_video', 'essentials.textToVideo'],
['image_to_video', 'essentials.imageToVideo'],
['pose_to_video', 'essentials.poseToVideo'],
['canny_to_video', 'essentials.cannyToVideo'],
['depth_to_video', 'essentials.depthToVideo']
['text_to_video', 'essentials.textToVideo', 'video generation'],
['image_to_video', 'essentials.imageToVideo', 'video generation'],
['pose_to_video', 'essentials.poseToVideo', 'video generation'],
['canny_to_video', 'essentials.cannyToVideo', 'video generation'],
['depth_to_video', 'essentials.depthToVideo', 'video generation']
]
function resolveBlueprintDisplayName(
@@ -88,6 +93,59 @@ function resolveBlueprintDisplayName(
return undefined
}
/**
* Resolves the icon class for a blueprint node based on its prefix.
* E.g. `SubgraphBlueprint.canny_to_image_flux` → `"icon-[comfy--canny-to-image]"`
*/
export function resolveBlueprintIcon(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const iconName = prefix.replace(/_$/, '').replaceAll('_', '-')
return `icon-[comfy--${iconName}]`
}
}
return undefined
}
/**
* Extracts the provider/model suffix from a blueprint name for disambiguation.
* E.g. `SubgraphBlueprint.text_to_image_flux_1` → `"Flux 1"`
*/
export function resolveBlueprintSuffix(nodeName: string): string | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
const raw = blueprintName.slice(prefix.length).replace(/^_/, '')
if (!raw) return undefined
return raw
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
}
return undefined
}
/**
* Returns the essentials category for a blueprint node based on its name,
* or `undefined` if the blueprint doesn't belong in the essentials tab.
*/
export function resolveBlueprintEssentialsCategory(
nodeName: string
): EssentialsCategory | undefined {
if (!nodeName.startsWith(BLUEPRINT_PREFIX)) return undefined
const blueprintName = nodeName.slice(BLUEPRINT_PREFIX.length)
for (const [prefix, , category] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
return category
}
}
return undefined
}
/**
* Resolves the Essentials tab display name for a given node definition.
* Returns `undefined` if the node has no Essentials display name mapping.

View File

@@ -2,15 +2,28 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export type ResolvedPromotedWidget = {
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
}
export function isPromotedWidgetView(

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
@@ -49,9 +55,18 @@ export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
class PromotedWidgetView implements IPromotedWidgetView {
@@ -76,11 +91,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -92,7 +113,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get name(): string {
return this.displayName ?? this.sourceWidgetName
return this.identityName ?? this.sourceWidgetName
}
get y(): number {
@@ -180,15 +201,58 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName ?? this.sourceWidgetName
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
@@ -230,21 +294,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(
@@ -287,7 +357,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
}
@@ -301,7 +372,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -341,7 +413,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
)
}

View File

@@ -18,6 +18,7 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -118,9 +119,12 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -129,7 +133,9 @@ describe('pruneDisconnected', () => {
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(warnSpy).toHaveBeenCalledOnce()
})
@@ -143,8 +149,8 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -154,8 +160,8 @@ describe('pruneDisconnected', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -255,13 +261,75 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
const subgraph = createTestSubgraph()
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
})
})
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -11,7 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/useNodeCanvasImagePreview'
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -26,6 +27,30 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -51,16 +76,14 @@ export function promoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.promote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Promoted widget "${widgetName}" on node ${node.id}`,
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -71,16 +94,14 @@ export function demoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.demote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Demoted widget "${widgetName}" on node ${node.id}`,
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -110,10 +131,9 @@ export function addWidgetPromotionOptions(
) {
const store = usePromotionStore()
const parents = getParentNodes()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -147,10 +167,9 @@ export function tryToggleWidgetPromotion() {
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -219,12 +238,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
@@ -242,20 +259,18 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
canvasSource
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
continue
}
@@ -271,8 +286,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(n.id),
getWidgetName(w)
toPromotionSource(n, w)
)
}
subgraphNode.computeSize(subgraphNode.size)
@@ -285,17 +299,16 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
[]
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.interiorNodeId)
const node = subgraph.getNodeById(entry.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.widgetName
(iw) => iw.name === entry.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
@@ -315,9 +328,26 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
)
)
}

View File

@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -51,7 +52,8 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -61,6 +63,7 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,7 +20,8 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
@@ -28,6 +29,7 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
@@ -37,7 +39,7 @@ function traversePromotedWidgetChain(
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
@@ -48,8 +50,10 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -69,22 +73,42 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find(
(entry: IBaseWidget) => entry.name === widgetName
)
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
if (!widget) return undefined
return { node, widget }
@@ -93,10 +117,11 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
}

View File

@@ -14,7 +14,8 @@ export function resolvePromotedWidgetSource(
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved

View File

@@ -6,7 +6,8 @@ import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphI
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -61,6 +62,7 @@ function addLinkedInteriorInput(
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.clearAllMocks()
})
@@ -121,6 +123,21 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('seed_input')
})
test('resolves the first connected link when multiple links exist', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
// First connected wins — consistent with SubgraphNode._resolveLinkedPromotionBySubgraphInput
expect(result).toBe('first_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(
@@ -144,4 +161,33 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('ok')
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
})
test('returns first link result with 3+ links connected', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'third_input', 'thirdWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
expect(result).toBe('first_input')
})
test('returns undefined when all links fail to resolve', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
() => undefined
)
expect(result).toBeUndefined()
})
})

View File

@@ -19,9 +19,9 @@ export function resolveSubgraphInputLink<TResult>(
)
if (!inputSlot) return undefined
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
// Iterate forward so the first connected source is the promoted representative,
// matching SubgraphNode._resolveLinkedPromotionBySubgraphInput.
for (const linkId of inputSlot.linkIds) {
const link = node.subgraph.getLink(linkId)
if (!link) continue

View File

@@ -158,4 +158,93 @@ describe('resolveSubgraphInputTarget', () => {
expect(result).toBeUndefined()
})
test('resolves widget and non-widget inputs on the same nested SubgraphNode independently', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
const innerSubgraph = createTestSubgraph({
inputs: [
{ name: 'width', type: '*' },
{ name: 'audio', type: '*' }
]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 820
})
outerSubgraph.add(innerSubgraphNode)
const widthInput = innerSubgraphNode.addInput('width', '*')
innerSubgraphNode.addWidget('number', 'width', 0, () => undefined)
widthInput.widget = { name: 'width' }
const audioInput = innerSubgraphNode.addInput('audio', '*')
const widthSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'width'
)!
const audioSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'audio'
)!
widthSlot.connect(widthInput, innerSubgraphNode)
audioSlot.connect(audioInput, innerSubgraphNode)
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '820',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
})
test('three-level nesting returns immediate child target, not deepest', () => {
// outer → middle → inner (concrete)
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
concreteNode.id = 900
const concreteInput = concreteNode.addInput('seed_input', '*')
concreteNode.addWidget('number', 'seed', 0, () => undefined)
concreteInput.widget = { name: 'seed' }
innerSubgraph.add(concreteNode)
innerSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const middleSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 901
})
middleSubgraph.add(innerSubgraphNode)
const middleInput = innerSubgraphNode.addInput('seed', '*')
innerSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
middleInput.widget = { name: 'seed' }
middleSubgraph.inputNode.slots[0].connect(middleInput, innerSubgraphNode)
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'seed'
])
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
id: 902
})
outerSubgraph.add(middleSubgraphNode)
const outerInput = middleSubgraphNode.addInput('seed', '*')
middleSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
outerInput.widget = { name: 'seed' }
outerSubgraph.inputNode.slots[0].connect(outerInput, middleSubgraphNode)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
// Should return the immediate child (middle), not the deepest (concrete)
expect(result).toMatchObject({
nodeId: '902',
widgetName: 'seed'
})
})
})

View File

@@ -1,10 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -19,6 +21,16 @@ export function resolveSubgraphInputTarget(
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -8,7 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -23,33 +24,35 @@ vi.mock('@/services/litegraphService', () => ({
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
@@ -57,18 +60,20 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'stringWidget' },
{ interiorNodeId: '2', widgetName: 'stringWidget' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
@@ -77,14 +82,14 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
@@ -92,19 +97,19 @@ describe('Subgraph proxyWidgets', () => {
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
@@ -114,12 +119,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
@@ -133,12 +138,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
@@ -154,46 +159,44 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -204,35 +207,38 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('removeWidgetByName removes from promotion list', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
test('removeWidget removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
subgraphNode.removeWidgetByName('widgetA')
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
subgraphNode.removeWidget(widgetA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
@@ -248,12 +254,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
@@ -265,23 +271,138 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
subgraphNode.graph!.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('shared_input', '*')
nodeA.addWidget('text', 'shared_input', 'first', () => {})
inputA.widget = { name: 'shared_input' }
subgraph.add(nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('shared_input', '*')
nodeB.addWidget('text', 'shared_input', 'second', () => {})
inputB.widget = { name: 'shared_input' }
subgraph.add(nodeB)
const nodeC = new LGraphNode('NodeC')
const inputC = nodeC.addInput('shared_input', '*')
nodeC.addWidget('text', 'shared_input', 'third', () => {})
inputC.widget = { name: 'shared_input' }
subgraph.add(nodeC)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
subgraph.inputNode.slots[0].connect(inputB, nodeB)
subgraph.inputNode.slots[0].connect(inputC, nodeC)
const firstRead = subgraphNode.widgets.map((w) => w.value)
const secondRead = subgraphNode.widgets.map((w) => w.value)
const thirdRead = subgraphNode.widgets.map((w) => w.value)
expect(firstRead).toStrictEqual(secondRead)
expect(secondRead).toStrictEqual(thirdRead)
expect(subgraphNode.widgets[0].value).toBe('first')
})
test('3-level nested promotion resolves concrete widget type and value', () => {
usePromotionStore()
// Level C: innermost subgraph with a concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'deep_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('deep_input', '*')
concreteNode.addWidget('number', 'deep_input', 42, () => {})
concreteInput.widget = { name: 'deep_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
// Level B: middle subgraph containing C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'mid_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
// Level A: outermost subgraph containing B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'outer_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
// Outermost promoted widget should resolve through all 3 levels
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
const view = subgraphNode.widgets[0]
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
// Remove: should clean up store AND input reference
subgraphNode.removeWidget(view)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(0)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote: should work correctly after cleanup
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('text')
expect(subgraphNode.widgets[0].value).toBe('value')
})
})

View File

@@ -1,56 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(interiorNode.id),
'seed'
)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,20 +0,0 @@
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { usePromotionStore } from '@/stores/promotionStore'
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(
rootGraph.id,
subgraphNodeId,
String(interiorNode.id),
widget.name
)
)
)
}

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