Compare commits

...

46 Commits

Author SHA1 Message Date
github-actions
bdedc6b684 [automated] Update test expectations 2025-12-09 23:57:03 +00:00
Benjamin Lu
516819cc5a Move cancel button into actionbar (#7297)
Move the interrupt control into the actionbar so cancellation sits with
the run controls.

- add a cancel button to the actionbar with the existing interrupt
tooltip and disabled state
- remove the cancel button and related execution wiring from the top
menu section to avoid duplication

- spacing/hover states of the new cancel control in both docked and
floating modes

- n/a

Tests: pnpm typecheck; pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7297-Move-cancel-button-into-actionbar-2c46d73d36508198b00cf011390289f6)
by [Unito](https://www.unito.io)
2025-12-09 15:31:52 -08:00
Comfy Org PR Bot
77051cabf5 [backport core/1.33] Fix desktop menu docs links regression (#7278)
Backport of #7181 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7278-backport-core-1-33-Fix-desktop-menu-docs-links-regression-2c46d73d36508115ab76e36fa53cd5d8)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-09 00:36:09 -07:00
Comfy Org PR Bot
88d92f53f0 1.33.13 (#7272)
Patch version increment to 1.33.13

**Base branch:** `core/1.33`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7272-1-33-13-2c46d73d36508182a967ce4bd4c39816)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-08 20:40:19 -07:00
Christian Byrne
9ad36303f6 [backport core/1.33] Fix copy not working when text is selected in dialogs (#7269)
## Summary
Backport of #7166 to core/1.33 branch.

- Fixes copy not working when text is selected in dialogs
- Also includes workflow priority fix (workflow checked before
parameters)

## Conflicts Resolved
- `src/scripts/app.ts`: Accepted incoming changes for workflow priority
logic

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7269-backport-core-1-33-Fix-copy-not-working-when-text-is-selected-in-dialogs-2c46d73d365081b690bfc9dc1548618e)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-12-08 19:52:31 -07:00
Comfy Org PR Bot
815e2d843d [backport core/1.33] Fix: Toolbox position desync (#7240)
Backport of #6962 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7240-backport-core-1-33-Fix-Toolbox-position-desync-2c36d73d365081c5b340e6e90b56fd0c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 19:42:09 -07:00
Benjamin Lu
baf8fb918a [backport core/1.33] Restore Cancel Button (#7266)
Backport of 259e9563c8 to core/1.33.
Resolved conflicts in TopMenuSection.vue and reverted binary snapshots.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7266-Backport-Restore-Cancel-Button-to-core-1-33-2c46d73d3650812f996cd959e5e29cfa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-08 19:29:40 -07:00
Comfy Org PR Bot
cb6d33007c [backport core/1.33] Fix workflow loading from PNG images with both workflow and parameter… (#7265)
Backport of #7154 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7265-backport-core-1-33-Fix-workflow-loading-from-PNG-images-with-both-workflow-and-paramete-2c46d73d365081e2b6f2dcbec3f2ba04)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Rvage <skentler@protonmail.com>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-12-08 19:21:44 -07:00
Comfy Org PR Bot
386e10516e [backport core/1.33] Fix zombie linkIds on node deletion, add safety check (#7264)
Backport of #7153 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7264-backport-core-1-33-Fix-zombie-linkIds-on-node-deletion-add-safety-check-2c46d73d365081ae9161f6b464a8e446)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 19:21:32 -07:00
Comfy Org PR Bot
03a99e2eed [backport core/1.33] On adding output matchType, initialize type (#7263)
Backport of #7161 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7263-backport-core-1-33-On-adding-output-matchType-initialize-type-2c46d73d365081d8bb8bc55cf1ef9a6e)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 19:21:26 -07:00
Comfy Org PR Bot
8b2620ee0f [backport core/1.33] fix: preserve node selection on right-click (#7262)
Backport of #7162 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7262-backport-core-1-33-fix-preserve-node-selection-on-right-click-2c46d73d36508198a959dd8b2cd99fd8)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2025-12-08 19:21:18 -07:00
Comfy Org PR Bot
5537877f49 [backport core/1.33] Fix issue No#7155: Nodes 2.0: Load Audio node is broken (#7261)
Backport of #7175 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7261-backport-core-1-33-Fix-issue-No-7155-Nodes-2-0-Load-Audio-node-is-broken-2c46d73d36508149b7d4eeddbc05919f)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2025-12-08 19:21:10 -07:00
Comfy Org PR Bot
2ab1a0cf2b [backport core/1.33] feat(api-nodes): add pricing for seedream4.5 (#7260)
Backport of #7189 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7260-backport-core-1-33-feat-api-nodes-add-pricing-for-seedream4-5-2c46d73d36508130bf7bd755f6eed66c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-08 17:55:47 -07:00
Comfy Org PR Bot
8192bbcae8 [backport core/1.33] fix: preserve custom nodes i18n data when locales are lazily loaded (#7259)
Backport of #7214 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7259-backport-core-1-33-fix-preserve-custom-nodes-i18n-data-when-locales-are-lazily-loaded-2c46d73d3650813ea30bff1b39e78bd2)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-08 17:55:40 -07:00
Comfy Org PR Bot
b17ab15e90 [backport core/1.33] fix: preserve Vue node reactivity during undo/redo operations (#7257)
Backport of #7222 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7257-backport-core-1-33-fix-preserve-Vue-node-reactivity-during-undo-redo-operations-2c46d73d36508184aff1c7c0e8e82408)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-08 17:55:29 -07:00
Comfy Org PR Bot
195d779035 [backport core/1.33] When moving subgraphInput link, properly disconnect old link (#7255)
Backport of #7229 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7255-backport-core-1-33-When-moving-subgraphInput-link-properly-disconnect-old-link-2c46d73d365081cda606e81ec4cbfeec)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-08 17:55:22 -07:00
Comfy Org PR Bot
c814db1861 [backport core/1.33] Fix job details popover sticking after cancel/delete (#7256)
Backport of #6930 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7256-backport-core-1-33-Fix-job-details-popover-sticking-after-cancel-delete-2c46d73d3650819ba0ffccb9e41e8783)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-08 17:32:11 -07:00
Comfy Org PR Bot
87f8de7759 [backport core/1.33] Hotfix: Templates spec (#7254)
Backport of #7235 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7254-backport-core-1-33-Hotfix-Templates-spec-2c46d73d36508168802bef2990c14d4d)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 17:32:05 -07:00
Comfy Org PR Bot
623954e582 [backport core/1.33] fix: should allow autoCols=true when server doesn't provide size (#7249)
Backport of #7132 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7249-backport-core-1-33-fix-should-allow-autoCols-true-when-server-doesn-t-provide-size-2c36d73d365081679b89fdf5ea4083d2)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-08 17:15:08 -07:00
Comfy Org PR Bot
6e541b7c46 [backport core/1.33] Fix skeleton loaders for Image/Video Previews (#7248)
Backport of #7094 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7248-backport-core-1-33-Fix-skeleton-loaders-for-Image-Video-Previews-2c36d73d3650814c9187f6321af27b96)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-08 17:14:54 -07:00
Comfy Org PR Bot
2561dd9ac0 [backport core/1.33] fix: should only trigger asset panel when it opening (#7247)
Backport of #7098 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7247-backport-core-1-33-fix-should-only-trigger-asset-panel-when-it-opening-2c36d73d365081caacf1c68bb8020b45)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-08 17:14:49 -07:00
Comfy Org PR Bot
4ed7c29f9a [backport core/1.33] fix: handle Unicode characters in clipboard copy/paste and add Paste menu option (#7246)
Backport of #7103 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7246-backport-core-1-33-fix-handle-Unicode-characters-in-clipboard-copy-paste-and-add-Paste-2c36d73d365081d79d8de04456bdf9d8)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-08 17:13:24 -07:00
Comfy Org PR Bot
4231514baf [backport core/1.33] fix: layout scale to handle downscale correctly (#7245)
Backport of #7109 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7245-backport-core-1-33-fix-layout-scale-to-handle-downscale-correctly-2c36d73d365081b5a4cbd6e1215aa92e)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-12-08 17:13:17 -07:00
Christian Byrne
79cdd0c06a [backport core/1.33] fix: Add fraction digits props to number input widget (backport #7033) (#7250)
## Summary
Backport of #7033 to core/1.33 branch.

- Adds fraction digits props to number input widget

## Conflicts Resolved
- Screenshot snapshots updated to match the incoming version

Fixes the manual backport requested after auto-backport failed due to
binary file conflicts.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7250-backport-core-1-33-fix-Add-fraction-digits-props-to-number-input-widget-backport-703-2c36d73d365081ce8b8ad86f1fc63df7)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-08 17:12:56 -07:00
Comfy Org PR Bot
f7278ddbee [backport core/1.33] A11y/style: Make properties panel use theme colors. Not comprehensive. (#7243)
Backport of #7036 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7243-backport-core-1-33-A11y-style-Make-properties-panel-use-theme-colors-Not-comprehensiv-2c36d73d365081d988c0f360b3a26872)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-08 16:27:10 -07:00
Comfy Org PR Bot
5b3401b8dd [backport core/1.33] Fix/vue nodes banner text (#7242)
Backport of #7007 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7242-backport-core-1-33-Fix-vue-nodes-banner-text-2c36d73d3650816d8356c5956102f4d9)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
2025-12-08 16:27:02 -07:00
Comfy Org PR Bot
0e757b88fa [backport core/1.33] Fix: Toolbox position desync (#7241)
Backport of #6962 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7241-backport-core-1-33-Fix-Toolbox-position-desync-2c36d73d3650810bb77bdd720976a119)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 16:26:50 -07:00
Comfy Org PR Bot
4ae12b8006 [backport core/1.33] Feat: Double Click on Image Assets to open the lightbox (#7239)
Backport of #6955 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7239-backport-core-1-33-Feat-Double-Click-on-Image-Assets-to-open-the-lightbox-2c36d73d36508107a168c49a5dad0bf7)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 16:26:42 -07:00
Comfy Org PR Bot
0c265922dc [backport core/1.33] Change node-component-header-surface color variable (#7238)
Backport of #6990 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7238-backport-core-1-33-Change-node-component-header-surface-color-variable-2c36d73d36508117b86aca4802a9c9a2)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-08 16:26:30 -07:00
Comfy Org PR Bot
7fb3d2b122 [backport core/1.33] feat(api-nodes-pricing): add prices for Kling-v2-5-turbo for node KlingStartEndFrame (#7237)
Backport of #6996 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7237-backport-core-1-33-feat-api-nodes-pricing-add-prices-for-Kling-v2-5-turbo-for-node-Kl-2c36d73d365081f09245e6bac055dc2b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-08 16:26:25 -07:00
Comfy Org PR Bot
1df90b0b5f 1.33.12 (#7120)
Patch version increment to 1.33.12

**Base branch:** `core/1.33`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7120-1-33-12-2be6d73d365081ef95c0fcaea6332cbd)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-02 20:29:43 -07:00
Comfy Org PR Bot
b24d155bcd [backport core/1.33] fix: subpath routing for reverse proxy, embedded frontends, nginx/apache subpath hosting, etc. (like SwarmUI) (#7116)
Backport of #7115 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7116-backport-core-1-33-fix-subpath-routing-for-reverse-proxy-embedded-frontends-nginx-ap-2be6d73d36508125a7cdf11672203e88)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-02 19:28:12 -07:00
Comfy Org PR Bot
cb50a45a80 1.33.11 (#7093)
Patch version increment to 1.33.11

**Base branch:** `core/1.33`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7093-1-33-11-2bd6d73d365081548ec5fc7bb02a1685)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-02 19:26:26 -07:00
Comfy Org PR Bot
3d7df5762b [backport core/1.33] fix: normalize path separators in comfyAPIPlugin for Windows compatibility (#7089)
Backport of #7087 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7089-backport-core-1-33-fix-normalize-path-separators-in-comfyAPIPlugin-for-Windows-compati-2bd6d73d3650817e83d5d2cc101dc9a1)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-01 21:48:49 -08:00
Comfy Org PR Bot
792554d6dd 1.33.10 (#7080)
Patch version increment to 1.33.10

**Base branch:** `core/1.33`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7080-1-33-10-2bc6d73d365081af8d5ac2009068ee43)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-01 14:39:26 -07:00
Comfy Org PR Bot
8df8149bd6 [backport core/1.33] feat(api-nodes-pricing): add prices for Kling O1 video model (#7078)
Backport of #7077 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7078-backport-core-1-33-feat-api-nodes-pricing-add-prices-for-Kling-O1-video-model-2bc6d73d365081429431cbe6c4645fba)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-01 14:18:19 -07:00
Comfy Org PR Bot
5e23ae318c [backport core/1.33] [fix] Prevent drag activation during Vue node resize (#7070)
Backport of #7064 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7070-backport-core-1-33-fix-Prevent-drag-activation-during-Vue-node-resize-2bc6d73d365081d29489d1704477bca4)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-30 20:44:29 -08:00
Christian Byrne
5606c977c8 [backport core/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7067)
## Summary
- Backport of #7063 to core/1.33
- Simplifies Vue node resize to bottom-right corner only

Cherry-picked from d76c59cb14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7067-backport-core-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508189b25acf94c8639569)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-30 20:20:23 -08:00
Comfy Org PR Bot
5103c8df3f [backport core/1.33] Expose LGraphNode.getSlotPosition (#7058)
Backport of #7042 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7058-backport-core-1-33-Expose-LGraphNode-getSlotPosition-2bb6d73d365081ce9a18f1d4a802276a)
by [Unito](https://www.unito.io)

Co-authored-by: niknah <niknah+github@gmail.com>
2025-11-30 16:42:50 -07:00
Comfy Org PR Bot
0da2d80708 [backport core/1.33] mark vue nodes menu toggle with beta tag (#7051)
Backport of #7047 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7051-backport-core-1-33-mark-vue-nodes-menu-toggle-with-beta-tag-2bb6d73d365081b09428d6f0bb21dc67)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:17 -07:00
Comfy Org PR Bot
93b06525cc [backport core/1.33] feat(api-nodes-pricing): add prices for ByteDance seedance-1-0-pro-fast model (#7029)
Backport of #7026 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7029-backport-core-1-33-feat-api-nodes-pricing-add-prices-for-ByteDance-seedance-1-0-pro-f-2b96d73d365081e48660fa00f236fae9)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-29 15:09:51 -07:00
Comfy Org PR Bot
6867d84ec1 [backport core/1.33] Remove app.graph usage from widgetInput code (#7010)
Backport of #7008 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7010-backport-core-1-33-Remove-app-graph-usage-from-widgetInput-code-2b86d73d3650811f9da1c5869f352e10)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-27 17:57:00 -07:00
Comfy Org PR Bot
d0bd4c26ca [backport core/1.33] fix: add filter for combo widgets (#7002)
Backport of #6999 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7002-backport-core-1-33-fix-add-filter-for-combo-widgets-2b86d73d365081d4ab3cc6d04b85bed1)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 14:20:37 -07:00
Christian Byrne
9f19c8e10e [backport core/1.33] fix: Vue Node <-> Litegraph node height offset normalization (#6979)
## Summary
Backport of #6966 onto core/1.33.

- cherry-picked 29dbfa3f
- resolved the zoomed-in ctrl+shift snapshot conflict by taking upstream
expectations

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6979-backport-core-1-33-fix-Vue-Node-Litegraph-node-height-offset-normalization-2b86d73d365081748ef2d6f47ce7b1d3)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 22:38:09 -07:00
Christian Byrne
36b8972442 [backport core/1.33] fix: remove LOD from vue nodes (#6984)
## Summary
Backport of #6950 onto core/1.33 (clean cherry-pick of 4b87b1fdc).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6984-backport-core-1-33-fix-remove-LOD-from-vue-nodes-2b86d73d36508151bf1ae4a879016211)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 21:40:53 -07:00
Christian Byrne
6f77d274a4 [backport core/1.33] fix: don't use registry when only checking for presence of missing nodes (#6974)
## Summary
Backport of #6965 onto core/1.33 (clean cherry-pick of 83f04490b).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6974-backport-core-1-33-fix-don-t-use-registry-when-only-checking-for-presence-of-missing-n-2b86d73d3650813dac37e224f857f296)
by [Unito](https://www.unito.io)
2025-11-26 17:50:32 -07:00
107 changed files with 1348 additions and 1102 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -170,9 +170,7 @@ test.describe('Templates', () => {
// Verify English titles are shown as fallback
await expect(
comfyPage.templates.content.getByRole('heading', {
name: 'Image Generation'
})
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,54 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Node Resizing', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
// Select the node first (this was causing the bug)
await node.header.click()
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Verify position unchanged after selection
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
// Get final position and size
const finalBox = await node.boundingBox()
if (!finalBox) throw new Error('Node bounding box not found after resize')
// Position should NOT have changed (the bug was position drift)
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
// Size should have increased
expect(finalBox.width).toBeGreaterThan(initialBox.width)
expect(finalBox.height).toBeGreaterThan(initialBox.height)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -1,49 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
await comfyPage.vueNodes.waitForNodes()
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
expect(initialNodeCount).toBeGreaterThan(0)
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
const vueNodesContainer = comfyPage.vueNodes.nodes
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
const comboboxesInNodes = vueNodesContainer.getByRole('combobox')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
await comfyPage.zoom(120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
await expect(textboxesInNodes.first()).toBeHidden()
await expect(comboboxesInNodes.first()).toBeHidden()
await comfyPage.zoom(-120, 10)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-lod-inactive.png'
)
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -88,12 +88,14 @@ export function comfyAPIPlugin(isDev: boolean): Plugin {
if (result.exports.length > 0) {
const projectRoot = process.cwd()
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
const relativePath = path
.relative(path.join(projectRoot, 'src'), id)
.replace(/\\/g, '/')
const shimFileName = relativePath.replace(/\.ts$/, '.js')
let shimContent = `// Shim for ${relativePath}\n`
const fileKey = relativePath.replace(/\.ts$/, '').replace(/\\/g, '/')
const fileKey = relativePath.replace(/\.ts$/, '')
const warningMessage = getWarningMessage(fileKey, shimFileName)
if (warningMessage) {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.9",
"version": "1.33.13",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -194,7 +194,7 @@
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-surface: var(--color-white);
--node-component-header-surface: var(--color-smoke-400);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
@@ -1190,24 +1190,19 @@ dialog::backdrop {
.litegraph.litecontextmenu,
.litegraph.litecontextmenu.dark {
z-index: 9999 !important;
background-color: var(--comfy-menu-bg) !important;
background-color: var(--comfy-menu-bg);
}
.litegraph.litecontextmenu
.litemenu-entry:hover:not(.disabled):not(.separator) {
background-color: var(--comfy-menu-hover-bg, var(--border-color)) !important;
color: var(--fg-color);
}
.litegraph.litecontextmenu .litemenu-entry.submenu,
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
background-color: var(--comfy-menu-bg) !important;
color: var(--input-text);
background-color: var(--comfy-menu-bg);
color: var(--fg-color);
}
.litegraph.litecontextmenu input {
background-color: var(--comfy-input-bg) !important;
color: var(--input-text) !important;
background-color: var(--comfy-input-bg);
color: var(--input-text);
}
.comfy-context-menu-filter {
@@ -1248,14 +1243,14 @@ dialog::backdrop {
.litegraph.litesearchbox {
z-index: 9999 !important;
background-color: var(--comfy-menu-bg) !important;
background-color: var(--comfy-menu-bg);
overflow: hidden;
display: block;
}
.litegraph.litesearchbox input,
.litegraph.litesearchbox select {
background-color: var(--comfy-input-bg) !important;
background-color: var(--comfy-input-bg);
color: var(--input-text);
}
@@ -1329,57 +1324,6 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform;
}
/* START LOD specific styles */
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.isLOD .lg-node {
box-shadow: none;
filter: none;
backdrop-filter: none;
text-shadow: none;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
}
.isLOD .lg-node-header {
border-radius: 0;
pointer-events: none;
}
.isLOD .lg-node-widgets {
pointer-events: none;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

View File

@@ -30,6 +30,17 @@
/>
<ComfyRunButton />
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
size="sm"
class="ml-2 bg-destructive-background text-base-foreground transition-colors duration-200 ease-in-out hover:bg-destructive-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-destructive-background"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
</Panel>
</div>
@@ -43,17 +54,24 @@ import {
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -255,6 +273,16 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',

View File

@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {

View File

@@ -19,7 +19,7 @@ import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import type { LogEntry, LogsWsMessage, TerminalSize } from '@/schemas/apiSchema'
import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
@@ -32,27 +32,22 @@ const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
// `autoCols` is false because we don't want the progress bar in the terminal
// to render incorrectly as the progress bar is rendered based on the
// server's terminal size.
// Apply a min cols of 80 for colab environments
// Auto-size terminal to fill container width.
// minCols: 80 ensures minimum width for colab environments.
// See https://github.com/comfyanonymous/ComfyUI/issues/6396
useAutoSize({ root, autoRows: true, autoCols: false, minCols: 80 })
useAutoSize({ root, autoRows: true, autoCols: true, minCols: 80 })
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
if (size) {
terminal.resize(size.cols, terminal.rows)
}
const update = (entries: Array<LogEntry>) => {
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries, e.detail.size)
update(e.detail.entries)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries, logs.size)
update(logs.entries)
}
const watchLogs = async () => {

View File

@@ -64,11 +64,13 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
item: MenuItem
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()

View File

@@ -125,7 +125,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -384,9 +384,7 @@ useEventListener(
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
Object.entries(i18nData).forEach(([locale, message]) => {
i18n.global.mergeLocaleMessage(locale, message)
})
mergeCustomNodesI18n(i18nData)
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
}

View File

@@ -36,12 +36,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
defineProps<{ displayedJobGroups: JobGroup[] }>()
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
@@ -89,4 +89,26 @@ const onDetailsLeave = (jobId: string) => {
hideTimer.value = null
}, 150)
}
const resetActiveDetails = () => {
clearHideTimer()
clearShowTimer()
activeDetailsId.value = null
}
watch(
() => props.displayedJobGroups,
(groups) => {
const activeId = activeDetailsId.value
if (!activeId) return
const hasActiveJob = groups.some((group) =>
group.items.some((item) => item.id === activeId)
)
if (!hasActiveJob) resetActiveDetails()
}
)
onBeforeUnmount(resetActiveDetails)
</script>

View File

@@ -135,7 +135,7 @@
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.delete')"
@click.stop="emit('delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
@@ -150,7 +150,7 @@
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
@@ -190,7 +190,7 @@
size="sm"
class="size-6 transform gap-1 rounded bg-destructive-background text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background-hover hover:opacity-95"
:aria-label="t('g.cancel')"
@click.stop="emit('cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
@@ -355,6 +355,18 @@ const computedShowClear = computed(() => {
return props.state !== 'completed'
})
const emitDetailsLeave = () => emit('details-leave', props.jobId)
const onCancelClick = () => {
emitDetailsLeave()
emit('cancel')
}
const onDeleteClick = () => {
emitDetailsLeave()
emit('delete')
}
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)

View File

@@ -73,6 +73,7 @@
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
@@ -101,6 +102,7 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'

View File

@@ -3,7 +3,7 @@
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center text-sm">
<div class="flex items-center text-sm text-white">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
@@ -17,7 +17,7 @@
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4"
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
unstyled
@click="handleDismiss"
>

View File

@@ -1,5 +1,5 @@
import { useElementBounding, useRafFn } from '@vueuse/core'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch, watchEffect } from 'vue'
import type { Ref } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
@@ -157,6 +157,14 @@ export function useSelectionToolboxPosition(
// Sync with canvas transform
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
watchEffect(() => {
if (visible.value) {
startSync()
} else {
stopSync()
}
})
// Watch for selection changes
watch(
() => canvasStore.getCanvas().state.selectionChanged,
@@ -173,11 +181,6 @@ export function useSelectionToolboxPosition(
}
updateSelectionBounds()
canvasStore.getCanvas().state.selectionChanged = false
if (visible.value) {
startSync()
} else {
stopSync()
}
}
},
{ immediate: true }

View File

@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -11,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -26,11 +26,6 @@ function useVueNodeLifecycleIndividual() {
let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -44,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const makeOmniProDurationCalculator =
(pricePerSecond: number): PricingFunction =>
(node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
const seconds = parseFloat(String(durationWidget.value))
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
const cost = pricePerSecond * seconds
return `$${cost.toFixed(2)}/Run`
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
'720p': [0.51, 0.56],
'1080p': [1.18, 1.22]
},
'seedance-1-0-pro-fast': {
'480p': [0.09, 0.1],
'720p': [0.21, 0.23],
'1080p': [0.47, 0.49]
},
'seedance-1-0-lite': {
'480p': [0.17, 0.18],
'720p': [0.37, 0.41],
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
}
}
const modelKey = model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const modelKey = model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const resKey = resolution.includes('1080')
? '1080p'
@@ -623,7 +645,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Same pricing matrix as KlingTextToVideoNode
if (modeValue.includes('v2-1')) {
if (modeValue.includes('v2-5-turbo')) {
if (modeValue.includes('10')) {
return '$0.70/Run'
}
return '$0.35/Run' // 5s default
} else if (modeValue.includes('v2-1')) {
if (modeValue.includes('10s')) {
return '$0.98/Run' // pro, 10s
}
@@ -699,6 +726,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingVirtualTryOnNode: {
displayPrice: '$0.07/Run'
},
KlingOmniProTextToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProFirstLastFrameNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProImageToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProVideoToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.168)
},
KlingOmniProEditVideoNode: {
displayPrice: '$0.168/second'
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1726,6 +1768,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
@@ -1733,21 +1778,31 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
const model = String(modelWidget?.value ?? '').toLowerCase()
let pricePerImage = 0.03 // default for seedream-4-0-250828 and fallback
if (model.includes('seedream-4-5-251128')) {
pricePerImage = 0.04
} else if (model.includes('seedream-4-0-250828')) {
pricePerImage = 0.03
}
const maxImages = Number(maxImagesWidget.value)
if (!sequentialGenerationWidget || !maxImagesWidget) {
return `$${pricePerImage}/Run ($${pricePerImage} for one output image)`
}
const seqMode = String(sequentialGenerationWidget.value).toLowerCase()
if (seqMode === 'disabled') {
return `$${pricePerImage}/Run`
}
const maxImagesRaw = Number(maxImagesWidget.value)
const maxImages =
Number.isFinite(maxImagesRaw) && maxImagesRaw > 0 ? maxImagesRaw : 1
if (maxImages === 1) {
return '$0.03/Run'
return `$${pricePerImage}/Run`
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
const totalCost = (pricePerImage * maxImages).toFixed(2)
return `$${totalCost}/Run ($${pricePerImage} for one output image)`
}
},
ByteDanceTextToVideoNode: {
@@ -1873,6 +1928,10 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],
KlingOmniProVideoToVideoNode: ['duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],

View File

@@ -1,42 +0,0 @@
import type { ComputedRef } from 'vue'
import { ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
setting: TSettingKey
vue: Settings[TSettingKey]
litegraph: Settings[TSettingKey]
}
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
config: RenderModeSettingConfig<TSettingKey>,
isVueMode: ComputedRef<boolean>
) {
const settingStore = useSettingStore()
const vueValue = ref(config.vue)
const litegraphValue = ref(config.litegraph)
const lastWasVue = ref<boolean | null>(null)
const load = async (vue: boolean) => {
if (lastWasVue.value === vue) return
if (lastWasVue.value !== null) {
const currentValue = settingStore.get(config.setting)
if (lastWasVue.value) {
vueValue.value = currentValue
} else {
litegraphValue.value = currentValue
}
}
await settingStore.set(
config.setting,
vue ? vueValue.value : litegraphValue.value
)
lastWasVue.value = vue
}
watch(isVueMode, load, { immediate: true })
}

View File

@@ -23,10 +23,16 @@ export const useCopy = () => {
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
// Use TextEncoder to handle Unicode characters properly
const base64Data = btoa(
String.fromCharCode(
...Array.from(new TextEncoder().encode(serializedData))
)
)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(btoa(serializedData))
clipboardHTMLWrapper.join(base64Data)
)
e.preventDefault()
e.stopImmediatePropagation()

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { useI18n } from 'vue-i18n'
import { i18n } from '@/i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
@@ -23,7 +23,7 @@ import { useI18n } from 'vue-i18n'
* ```
*/
export function useExternalLink() {
const { locale } = useI18n()
const locale = computed(() => String(i18n.global.locale.value))
const isChinese = computed(() => {
return locale.value === 'zh' || locale.value === 'zh-TW'

View File

@@ -14,9 +14,11 @@ function pasteClipboardItems(data: DataTransfer): boolean {
const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
if (!match) return false
try {
useCanvasStore()
.getCanvas()
._deserializeItems(JSON.parse(atob(match)), {})
// Decode UTF-8 safe base64
const binaryString = atob(match)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
const decodedData = new TextDecoder().decode(bytes)
useCanvasStore().getCanvas()._deserializeItems(JSON.parse(decodedData), {})
return true
} catch (err) {
console.error(err)

View File

@@ -7,9 +7,9 @@ import type {
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LLink,
Point
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
}
override applyToGraph(extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length) return
if (!this.outputs[0].links?.length || !this.graph) return
const links = [
...this.outputs[0].links.map((l) => app.graph.links[l]),
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
if (v && this.properties[replacePropertyName]) {
v = applyTextReplacements(app.graph, v as string)
v = applyTextReplacements(this.graph, v as string)
}
// For each output link copy our value over the original widget value
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
if (!config1) return
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
if (!isNumber) return
if (!isNumber || !this.graph) return
for (const linkId of links) {
const link = app.graph.links[linkId]
const link = this.graph.links[linkId]
if (!link) continue // Can be null when removing a node
const theirNode = app.graph.getNodeById(link.target_id)
const theirNode = this.graph.getNodeById(link.target_id)
if (!theirNode) continue
const theirInput = theirNode.inputs[link.target_slot]
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
return { type }
}
export function setWidgetConfig(
slot: INodeInputSlot | INodeOutputSlot,
config?: InputSpec
) {
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
if (!slot.widget) return
if (config) {
slot.widget[GET_CONFIG] = () => config
@@ -452,19 +449,18 @@ export function setWidgetConfig(
delete slot.widget
}
if ('link' in slot) {
const link = app.graph.links[slot.link ?? -1]
if (link) {
const originNode = app.graph.getNodeById(link.origin_id)
if (originNode && isPrimitiveNode(originNode)) {
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
}
if (!(slot instanceof NodeSlot)) return
const graph = slot.node.graph
if (!graph) return
const link = graph.links[slot.link ?? -1]
if (!link) return
const originNode = graph.getNodeById(link.origin_id)
if (!originNode || !isPrimitiveNode(originNode)) return
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
@@ -555,15 +551,6 @@ app.registerExtension({
}
)
function isNodeAtPos(pos: Point) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true
}
}
return false
}
// Double click a widget input to automatically attach a primitive
const origOnInputDblClick = nodeType.prototype.onInputDblClick
nodeType.prototype.onInputDblClick = function (
@@ -589,18 +576,18 @@ app.registerExtension({
// Create a primitive node
const node = LiteGraph.createNode('PrimitiveNode')
if (!node) return r
const graph = app.canvas.graph
if (!node || !graph) return r
this.graph?.add(node)
graph?.add(node)
// Calculate a position that won't directly overlap another node
const pos: [number, number] = [
this.pos[0] - node.size[0] - 30,
this.pos[1]
]
while (isNodeAtPos(pos)) {
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
}
node.pos = pos
node.connect(0, this, slot)

200
src/i18n.test.ts Normal file
View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
// Mock the JSON imports before importing i18n module
vi.mock('./locales/en/main.json', () => ({ default: { welcome: 'Welcome' } }))
vi.mock('./locales/en/nodeDefs.json', () => ({
default: { testNode: 'Test Node' }
}))
vi.mock('./locales/en/commands.json', () => ({
default: { save: 'Save' }
}))
vi.mock('./locales/en/settings.json', () => ({
default: { theme: 'Theme' }
}))
// Mock lazy-loaded locales
vi.mock('./locales/zh/main.json', () => ({ default: { welcome: '欢迎' } }))
vi.mock('./locales/zh/nodeDefs.json', () => ({
default: { testNode: '测试节点' }
}))
vi.mock('./locales/zh/commands.json', () => ({ default: { save: '保存' } }))
vi.mock('./locales/zh/settings.json', () => ({ default: { theme: '主题' } }))
describe('i18n', () => {
beforeEach(async () => {
vi.resetModules()
})
describe('mergeCustomNodesI18n', () => {
it('should immediately merge data for already loaded locales (en)', async () => {
// English is pre-loaded, so merge should work immediately
mergeCustomNodesI18n({
en: {
customNode: {
title: 'Custom Node Title'
}
}
})
// Verify the custom node data was merged
const messages = i18n.global.getLocaleMessage('en') as Record<
string,
unknown
>
expect(messages.customNode).toEqual({ title: 'Custom Node Title' })
})
it('should store data for not-yet-loaded locales', async () => {
const { i18n, mergeCustomNodesI18n } = await import('./i18n')
// Chinese is not pre-loaded, data should be stored but not merged yet
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
}
}
})
// zh locale should not exist yet (not loaded)
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
// Either empty or doesn't have our custom data merged directly
// (since zh wasn't loaded yet, mergeLocaleMessage on non-existent locale
// may create an empty locale or do nothing useful)
expect(zhMessages.customNode).toBeUndefined()
})
it('should merge stored data when locale is lazily loaded', async () => {
// First, store custom nodes i18n data (before locale is loaded)
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
}
}
})
await loadLocale('zh')
// Verify both the base locale data and custom node data are present
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.welcome).toBe('欢迎')
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
})
it('should preserve custom node data when locale is loaded after merge', async () => {
// Simulate the real scenario:
// 1. Custom nodes i18n is loaded first
mergeCustomNodesI18n({
zh: {
customNode: {
title: '自定义节点标题'
},
settingsCategories: {
Hotkeys: '快捷键'
}
}
})
// 2. Then locale is lazily loaded (this would previously overwrite custom data)
await loadLocale('zh')
// 3. Verify custom node data is still present
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.customNode).toEqual({ title: '自定义节点标题' })
expect(zhMessages.settingsCategories).toEqual({ Hotkeys: '快捷键' })
// 4. Also verify base locale data is present
expect(zhMessages.welcome).toBe('欢迎')
expect(zhMessages.nodeDefs).toEqual({ testNode: '测试节点' })
})
it('should handle multiple locales in custom nodes i18n data', async () => {
// Merge data for multiple locales
mergeCustomNodesI18n({
en: {
customPlugin: { name: 'Easy Use' }
},
zh: {
customPlugin: { name: '简单使用' }
}
})
// English should be merged immediately (pre-loaded)
const enMessages = i18n.global.getLocaleMessage('en') as Record<
string,
unknown
>
expect(enMessages.customPlugin).toEqual({ name: 'Easy Use' })
await loadLocale('zh')
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
expect(zhMessages.customPlugin).toEqual({ name: '简单使用' })
})
it('should handle calling mergeCustomNodesI18n multiple times', async () => {
// Use fresh module instance to ensure clean state
vi.resetModules()
const { i18n, loadLocale, mergeCustomNodesI18n } = await import('./i18n')
mergeCustomNodesI18n({
zh: { plugin1: { name: '插件1' } }
})
mergeCustomNodesI18n({
zh: { plugin2: { name: '插件2' } }
})
await loadLocale('zh')
const zhMessages = i18n.global.getLocaleMessage('zh') as Record<
string,
unknown
>
// Only the second call's data should be present
expect(zhMessages.plugin2).toEqual({ name: '插件2' })
// First call's data is overwritten
expect(zhMessages.plugin1).toBeUndefined()
})
})
describe('loadLocale', () => {
it('should not reload already loaded locale', async () => {
await loadLocale('zh')
await loadLocale('zh')
// Should complete without error (second call returns early)
})
it('should warn for unsupported locale', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await loadLocale('unsupported-locale')
expect(consoleSpy).toHaveBeenCalledWith(
'Locale "unsupported-locale" is not supported'
)
consoleSpy.mockRestore()
})
it('should handle concurrent load requests for same locale', async () => {
// Start multiple loads concurrently
const promises = [loadLocale('zh'), loadLocale('zh'), loadLocale('zh')]
await Promise.all(promises)
})
})
})

View File

@@ -90,6 +90,9 @@ const loadedLocales = new Set<string>(['en'])
// Track locales currently being loaded to prevent race conditions
const loadingLocales = new Map<string, Promise<void>>()
// Store custom nodes i18n data for merging when locales are lazily loaded
const customNodesI18nData: Record<string, unknown> = {}
/**
* Dynamically load a locale and its associated files (nodeDefs, commands, settings)
*/
@@ -133,6 +136,10 @@ export async function loadLocale(locale: string): Promise<void> {
i18n.global.setLocaleMessage(locale, messages as LocaleMessages)
loadedLocales.add(locale)
if (customNodesI18nData[locale]) {
i18n.global.mergeLocaleMessage(locale, customNodesI18nData[locale])
}
} catch (error) {
console.error(`Failed to load locale "${locale}":`, error)
throw error
@@ -146,6 +153,24 @@ export async function loadLocale(locale: string): Promise<void> {
return loadPromise
}
/**
* Stores the data for later use when locales are lazily loaded,
* and immediately merges data for already-loaded locales.
*/
export function mergeCustomNodesI18n(i18nData: Record<string, unknown>): void {
// Clear existing data and replace with new data
for (const key of Object.keys(customNodesI18nData)) {
delete customNodesI18nData[key]
}
Object.assign(customNodesI18nData, i18nData)
for (const [locale, message] of Object.entries(i18nData)) {
if (loadedLocales.has(locale)) {
i18n.global.mergeLocaleMessage(locale, message)
}
}
}
// Only include English in the initial bundle
const messages = {
en: buildLocale(en, enNodes, enCommands, enSettings)

View File

@@ -21,8 +21,8 @@
min-width: 100px;
color: #aaf;
padding: 0;
box-shadow: 0 0 10px black !important;
background-color: #2e2e2e !important;
box-shadow: 0 0 10px black;
background-color: #2e2e2e;
z-index: 10;
max-height: -webkit-fill-available;
overflow-y: auto;
@@ -36,10 +36,6 @@
}
}
.litegraph.litecontextmenu.dark {
background-color: #000 !important;
}
.litegraph.litecontextmenu .litemenu-title img {
margin-top: 2px;
margin-left: 2px;
@@ -51,14 +47,6 @@
padding: 2px;
}
.litegraph.litecontextmenu .litemenu-entry.submenu {
background-color: #2e2e2e !important;
}
.litegraph.litecontextmenu.dark .litemenu-entry.submenu {
background-color: #000 !important;
}
.litegraph .litemenubar ul {
font-family: Tahoma, sans-serif;
margin: 0;
@@ -132,14 +120,13 @@
.litegraph .litemenu-entry.separator {
display: block;
border-top: 1px solid #333;
border-bottom: 1px solid #666;
border-top: 1px solid var(--border-default);
width: 100%;
height: 0px;
margin: 3px 0 2px 0;
background-color: transparent;
padding: 0 !important;
cursor: default !important;
padding: 0;
cursor: default;
}
.litegraph .litemenu-entry.has_submenu {
@@ -155,8 +142,8 @@
}
.litegraph .litemenu-entry:hover:not(.disabled):not(.separator) {
background-color: #444 !important;
color: #eee;
background-color: var(--palette-interface-panel-hover-surface);
color: var(--content-hover-fg);
transition: all 0.2s;
}
@@ -259,7 +246,8 @@
margin-top: -150px;
margin-left: -200px;
background-color: #2a2a2a;
color: var(--base-foreground);
background-color: var(--comfy-menu-bg);
min-width: 400px;
min-height: 200px;
@@ -299,8 +287,7 @@
}
.litegraph .dialog .dialog-header {
color: #aaa;
border-bottom: 1px solid #161616;
border-bottom: 1px solid var(--border-default);
}
.litegraph .dialog .dialog-header {
@@ -310,11 +297,12 @@
height: 50px;
padding: 10px;
margin: 0;
border-top: 1px solid #1a1a1a;
border-top: 1px solid var(--border-default);
}
.litegraph .dialog .dialog-header .dialog-title {
font: 20px "Arial";
font: 1rem;
font-family: Inter, Arial, sans-serif;
margin: 4px;
padding: 4px 10px;
display: inline-block;
@@ -326,7 +314,7 @@
width: 100%;
min-height: 100px;
display: inline-block;
color: #aaa;
/* color: #aaa; */
/*background-color: black;*/
overflow: auto;
}
@@ -362,8 +350,7 @@
display: block;
width: calc(100% - 4px);
height: 1px;
border-top: 1px solid #000;
border-bottom: 1px solid #333;
border-top: 1px solid var(--border-default);
margin: 10px 2px;
padding: 0;
}
@@ -373,12 +360,8 @@
padding: 4px;
}
.litegraph .dialog .property:hover {
background: #545454;
}
.litegraph .dialog .property_name {
color: #737373;
color: var(--muted-foreground);
display: inline-block;
text-align: left;
vertical-align: top;
@@ -395,8 +378,8 @@
.litegraph .dialog .property_value {
display: inline-block;
text-align: right;
color: #aaa;
background-color: #1a1a1a;
color: var(--input-text);
background-color: var(--component-node-widget-background);
/*width: calc( 100% - 122px );*/
max-width: calc(100% - 162px);
min-width: 200px;
@@ -432,18 +415,18 @@
border-radius: 4px;
padding: 4px 20px;
margin-left: 0px;
background-color: #060606;
color: #8e8e8e;
background-color: var(--secondary-background);
color: var(--base-foreground);
}
.litegraph .dialog .btn:hover {
background-color: #111;
color: #fff;
background-color: var(--secondary-background-hover);
color: var(--base-foreground);
}
.litegraph .dialog .btn.delete:hover {
background-color: #f33;
color: black;
background-color: var(--color-danger-100);
color: var(--base-foreground);
}
.litegraph .bullet_icon {
@@ -497,11 +480,11 @@
.graphmenu-entry.danger,
.litemenu-entry.danger {
color: var(--error-text) !important;
color: var(--error-text);
}
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
color: var(--error-text) !important;
color: var(--error-text);
opacity: 0.8;
}
@@ -518,8 +501,7 @@
}
.graphmenu-entry.separator {
background-color: #111;
border-bottom: 1px solid #666;
background-color: var(--border-default);
height: 1px;
width: calc(100% - 20px);
-moz-width: calc(100% - 20px);
@@ -551,7 +533,7 @@
min-height: 2em;
background-color: #333;
font-size: 1.2em;
box-shadow: 0 0 10px black !important;
box-shadow: 0 0 10px black;
z-index: 10;
}

View File

@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -4043,16 +4044,25 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes`
const newPositions = created.map((node) => ({
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: node.size?.[1] ?? 200
}
}))
const newPositions = created
.filter((item): item is LGraphNode => item instanceof LGraphNode)
.map((node) => {
const fullHeight = node.size?.[1] ?? 200
const layoutHeight = LiteGraph.vueNodesMode
? removeNodeTitleHeight(fullHeight)
: fullHeight
return {
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: layoutHeight
}
}
})
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)
@@ -8021,7 +8031,13 @@ export class LGraphCanvas
has_submenu: true,
callback: LGraphCanvas.onMenuAdd
},
{ content: 'Add Group', callback: LGraphCanvas.onGroupAdd }
{ content: 'Add Group', callback: LGraphCanvas.onGroupAdd },
{
content: 'Paste',
callback: () => {
this.pasteFromClipboard()
}
}
// { content: "Arrange", callback: that.graph.arrange },
// {content:"Collapse All", callback: LGraphCanvas.onMenuCollapseAll }
]

View File

@@ -2,7 +2,8 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPos,
calculateInputSlotPosFromSlot,
calculateOutputSlotPos
calculateOutputSlotPos,
getSlotPosition
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -10,6 +11,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import { adjustColor } from '@/utils/colorUtil'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
@@ -44,8 +46,8 @@ import type {
Rect,
Size
} from './interfaces'
import { LiteGraph } from './litegraph'
import type { LGraphNodeConstructor, Subgraph, SubgraphNode } from './litegraph'
import { LiteGraph, Subgraph } from './litegraph'
import type { LGraphNodeConstructor, SubgraphNode } from './litegraph'
import {
createBounds,
isInRect,
@@ -3073,6 +3075,17 @@ export class LGraphNode
for (const link_id of links) {
const link_info = graph._links.get(link_id)
if (!link_info) continue
if (
link_info.target_id === SUBGRAPH_OUTPUT_ID &&
graph instanceof Subgraph
) {
const targetSlot = graph.outputNode.slots[link_info.target_slot]
if (targetSlot) {
targetSlot.linkIds.length = 0
} else {
console.error('Missing subgraphOutput slot when disconnecting link')
}
}
const target = graph.getNodeById(link_info.target_id)
graph._version++
@@ -3354,6 +3367,16 @@ export class LGraphNode
)
}
/**
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
getSlotPosition(slotIndex: number, isInput: boolean): Point {
return getSlotPosition(this, slotIndex, isInput)
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)

View File

@@ -63,6 +63,9 @@ export class ToInputFromIoNodeLink implements RenderLink {
if (existingLink) {
// Moving an existing link
const { input, inputNode } = existingLink.resolve(this.network)
if (inputNode && input)
this.node._disconnectNodeInput(inputNode, input, existingLink)
events.dispatch('input-moved', this)
} else {
// Creating a new link

View File

@@ -158,6 +158,7 @@ export class SubgraphOutput extends SubgraphSlot {
//should never have more than one connection
for (const linkId of this.linkIds) {
const link = subgraph.links[linkId]
if (!link) continue
subgraph.removeLink(linkId)
const { output, outputNode } = link.resolve(subgraph)
if (output)

View File

@@ -1,5 +1,6 @@
{
"g": {
"beta": "Beta",
"user": "User",
"currentUser": "Current user",
"empty": "Empty",

View File

@@ -59,35 +59,29 @@
<!-- Media actions - show on hover or when playing -->
<IconGroup v-else-if="showActionsOverlay">
<div v-tooltip.top="$t('mediaAsset.actions.inspect')">
<IconButton
size="sm"
@click.stop="handleZoomClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
</div>
<div v-tooltip.top="$t('mediaAsset.actions.more')">
<MoreButton
ref="moreButtonRef"
size="sm"
@menu-opened="handleMenuOpened"
@menu-closed="handleMenuClosed"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<template #default="{ close }">
<MediaAssetMoreMenu
:close="close"
:show-delete-button="showDeleteButton"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
/>
</template>
</MoreButton>
</div>
<IconButton
v-tooltip.top="$t('mediaAsset.actions.inspect')"
size="sm"
@click.stop="handleZoomClick"
>
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
<MoreButton
ref="moreButtonRef"
v-tooltip.top="$t('mediaAsset.actions.more')"
size="sm"
@menu-opened="handleMenuOpened"
@menu-closed="handleMenuClosed"
>
<template #default="{ close }">
<MediaAssetMoreMenu
:close="close"
:show-delete-button="showDeleteButton"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
/>
</template>
</MoreButton>
</IconGroup>
</template>
@@ -101,8 +95,6 @@
size="sm"
:label="String(outputCount)"
@click.stop="handleOutputCountClick"
@mouseenter="handleOverlayMouseEnter"
@mouseleave="handleOverlayMouseLeave"
>
<template #icon>
<i class="icon-[lucide--layers] size-4" />
@@ -216,7 +208,6 @@ const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isOverlayHovered = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
@@ -299,7 +290,7 @@ const durationChipClasses = computed(() => {
})
const isCardOrOverlayHovered = computed(
() => isHovered.value || isOverlayHovered.value || isMenuOpen.value
() => isHovered.value || isMenuOpen.value
)
// Show static chips when NOT hovered and NOT playing (normal state)
@@ -320,14 +311,6 @@ const showActionsOverlay = computed(
(isCardOrOverlayHovered.value || isVideoPlaying.value)
)
const handleOverlayMouseEnter = () => {
isOverlayHovered.value = true
}
const handleOverlayMouseLeave = () => {
isOverlayHovered.value = false
}
const handleZoomClick = () => {
if (asset) {
emit('zoom', asset)

View File

@@ -1,6 +1,7 @@
<template>
<div
class="relative size-full overflow-hidden rounded bg-modal-card-placeholder-background"
@dblclick="emit('view')"
>
<img
v-if="!error"
@@ -28,6 +29,7 @@ const { asset } = defineProps<{
const emit = defineEmits<{
'image-loaded': [width: number, height: number]
view: []
}>()
const { state, error, isReady } = useImage({

View File

@@ -8,9 +8,11 @@ import {
} from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
export function useSettingSearch() {
const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
@@ -54,7 +56,11 @@ export function useSettingSearch() {
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
if (
setting.type === 'hidden' ||
setting.deprecated ||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
) {
return false
}

View File

@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SettingPanelItem {
node: SettingTreeNode
@@ -31,10 +32,14 @@ export function useSettingUI(
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
(setting: SettingParams) => setting.type !== 'hidden'
(setting: SettingParams) =>
setting.type !== 'hidden' &&
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)

View File

@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
step: 1
},
defaultValue: 8,
versionAdded: '1.26.7'
versionAdded: '1.26.7',
hideInVueNodes: true
},
{
id: 'Comfy.Canvas.SelectionToolbox',

View File

@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
// sortOrder for sorting settings within a group. Higher values appear first.
// Default is 0 if not specified.
sortOrder?: number
hideInVueNodes?: boolean
}
/**

View File

@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
}
})
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: vi.fn(() => ({
isLOD: false
}))
}))
function createMockCanvas(): LGraphCanvas {
return {
canvas: {

View File

@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
// Vue resizing state to prevent drag from activating during resize
public isResizingVueNodes = ref(false)
constructor() {
// Initialize Yjs data structures
@@ -952,6 +956,15 @@ class LayoutStoreImpl implements LayoutStore {
return this.currentActor
}
/**
* Clean up refs and triggers for a node when its Vue component unmounts.
* This should be called from the component's onUnmounted hook.
*/
cleanupNodeRef(nodeId: NodeId): void {
this.nodeRefs.delete(nodeId)
this.nodeTriggers.delete(nodeId)
}
/**
* Initialize store with existing nodes
*/
@@ -960,8 +973,10 @@ class LayoutStoreImpl implements LayoutStore {
): void {
this.ydoc.transact(() => {
this.ynodes.clear()
this.nodeRefs.clear()
this.nodeTriggers.clear()
// Note: We intentionally do NOT clear nodeRefs and nodeTriggers here.
// Vue components may already hold references to these refs, and clearing
// them would break the reactivity chain. The refs will be reused when
// nodes are recreated, and stale refs will be cleaned up over time.
this.spatialIndex.clear()
this.linkSegmentSpatialIndex.clear()
this.slotSpatialIndex.clear()
@@ -991,6 +1006,9 @@ class LayoutStoreImpl implements LayoutStore {
// Add to spatial index
this.spatialIndex.insert(layout.id, layout.bounds)
})
// Trigger all existing refs to notify Vue of the new data
this.nodeTriggers.forEach((trigger) => trigger())
}, 'initialization')
}
@@ -1081,8 +1099,10 @@ class LayoutStoreImpl implements LayoutStore {
if (!this.ynodes.has(operation.nodeId)) return
this.ynodes.delete(operation.nodeId)
this.nodeRefs.delete(operation.nodeId)
this.nodeTriggers.delete(operation.nodeId)
// Note: We intentionally do NOT delete nodeRefs and nodeTriggers here.
// During undo/redo, Vue components may still hold references to the old ref.
// If we delete the trigger, Vue won't be notified when the node is re-created.
// The trigger will be called in finalizeOperation to notify Vue of the change.
// Remove from spatial index
this.spatialIndex.remove(operation.nodeId)
@@ -1414,8 +1434,8 @@ class LayoutStoreImpl implements LayoutStore {
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
if (updates.length === 0) return
// Set source to Vue for these DOM-driven updates
const originalSource = this.currentSource
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
this.currentSource = LayoutSource.Vue
const nodeIds: NodeId[] = []
@@ -1426,8 +1446,15 @@ class LayoutStoreImpl implements LayoutStore {
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
const normalizedBounds = shouldNormalizeHeights
? {
...bounds,
height: removeNodeTitleHeight(bounds.height)
}
: bounds
boundsRecord[nodeId] = {
bounds,
bounds: normalizedBounds,
previousBounds: currentLayout.bounds
}
nodeIds.push(nodeId)

View File

@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -43,12 +44,13 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
liteNode.size[1] !== targetHeight
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, layout.size.height])
liteNode.setSize([layout.size.width, targetHeight])
}
}

View File

@@ -4,8 +4,7 @@
:class="
cn(
'absolute inset-0 w-full h-full pointer-events-none',
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD && 'isLOD'
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
)
"
:style="transformStyle"
@@ -22,7 +21,6 @@ import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps {
@@ -31,9 +29,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const { camera, transformStyle, syncWithCanvas } = useTransformState()
const { isLOD } = useLOD(camera)
const { transformStyle, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {

View File

@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
DOM = 'dom',
External = 'external'
}

View File

@@ -0,0 +1,7 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -26,13 +26,19 @@
</div>
<!-- Loading State -->
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<Skeleton
v-if="isLoading && !videoError"
class="absolute inset-0 size-full"
border-radius="5px"
width="16rem"
height="16rem"
/>
<!-- Main Video -->
<video
v-else
v-if="!videoError"
:src="currentVideoUrl"
class="block size-full object-contain"
:class="cn('block size-full object-contain', isLoading && 'invisible')"
controls
loop
playsinline
@@ -83,20 +89,17 @@
</div>
</div>
<div class="relative">
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -109,8 +112,7 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
import { cn } from '@/utils/tailwindUtil'
interface VideoPreviewProps {
/** Array of video URLs to display */
@@ -147,7 +149,7 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
isLoading.value = false
isLoading.value = newUrls.length > 0
},
{ deep: true }
)

View File

@@ -26,15 +26,26 @@
</div>
<!-- Loading State -->
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
<Skeleton
v-if="isLoading && !imageError"
class="absolute inset-0 size-full"
border-radius="5px"
width="16rem"
height="16rem"
/>
<!-- Main Image -->
<img
v-else
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none"
:class="
cn(
'block size-full object-contain pointer-events-none',
isLoading && 'invisible'
)
"
@load="handleImageLoad"
@error="handleImageError"
/>
@@ -93,20 +104,17 @@
</div>
</div>
<div class="relative">
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -121,8 +129,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
import { cn } from '@/utils/tailwindUtil'
interface ImagePreviewProps {
/** Array of image URLs to display */
@@ -166,7 +173,7 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
isLoading.value = false
isLoading.value = newUrls.length > 0
},
{ deep: true }
)
@@ -226,7 +233,7 @@ const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = false
isLoading.value = true
imageError.value = false
}
}

View File

@@ -10,14 +10,13 @@
/>
<!-- Slot Name -->
<div class="relative h-full flex items-center min-w-0">
<div class="h-full flex items-center min-w-0">
<span
v-if="!dotOnly"
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
:class="cn('truncate text-xs font-normal', labelClasses)"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div>
</template>
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {

View File

@@ -99,18 +99,14 @@
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-1 flex-col gap-1 pb-2"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots :node-data="nodeData" />
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
@@ -121,17 +117,14 @@
</div>
</template>
<!-- Resize handles -->
<template v-if="!isCollapsed">
<div
v-for="handle in cornerResizeHandles"
:key="handle.id"
role="button"
:aria-label="handle.ariaLabel"
:class="cn(baseResizeHandleClasses, handle.classes)"
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
/>
</template>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
</template>
@@ -175,7 +168,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
@@ -267,7 +259,7 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
@@ -318,41 +310,6 @@ onMounted(() => {
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const POSITION_EPSILON = 0.01
type CornerResizeHandle = {
id: string
direction: ResizeHandleDirection
classes: string
ariaLabel: string
}
const cornerResizeHandles: CornerResizeHandle[] = [
{
id: 'se',
direction: { horizontal: 'right', vertical: 'bottom' },
classes: 'right-0 bottom-0 cursor-se-resize',
ariaLabel: t('g.resizeFromBottomRight')
},
{
id: 'ne',
direction: { horizontal: 'right', vertical: 'top' },
classes: 'right-0 top-0 cursor-ne-resize',
ariaLabel: t('g.resizeFromTopRight')
},
{
id: 'sw',
direction: { horizontal: 'left', vertical: 'bottom' },
classes: 'left-0 bottom-0 cursor-sw-resize',
ariaLabel: t('g.resizeFromBottomLeft')
},
{
id: 'nw',
direction: { horizontal: 'left', vertical: 'top' },
classes: 'left-0 top-0 cursor-nw-resize',
ariaLabel: t('g.resizeFromTopLeft')
}
]
const MIN_NODE_WIDTH = 225
@@ -365,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => {
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.setProperty('--node-width', `${clampedWidth}px`)
element.style.setProperty('--node-height', `${result.size.height}px`)
const currentPosition = position.value
const deltaX = Math.abs(result.position.x - currentPosition.x)
const deltaY = Math.abs(result.position.y - currentPosition.y)
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
moveNodeTo(result.position)
}
})
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
return (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event, direction, { ...position.value })
}
const handleResizePointerDown = (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event)
}
watch(isCollapsed, (collapsed) => {

View File

@@ -1,5 +0,0 @@
<template>
<div
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
></div>
</template>

View File

@@ -18,7 +18,7 @@
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="lod-toggle flex shrink-0 items-center px-0.5">
<div class="flex shrink-0 items-center px-0.5">
<IconButton
size="fit-content"
type="transparent"
@@ -44,7 +44,7 @@
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
class="flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
@@ -57,10 +57,9 @@
/>
</div>
</div>
<LODFallback />
</div>
<div class="lod-toggle flex shrink-0 items-center justify-between gap-2">
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
@@ -112,7 +111,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {

View File

@@ -5,11 +5,10 @@
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
class="text-xs font-normal truncate text-node-component-slot-text"
>
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -35,7 +34,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {

View File

@@ -1,141 +0,0 @@
# ComfyUI Widget LOD System: Architecture and Implementation
## Executive Summary
The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
## The Two Approaches: Reactive vs. Static LOD
### Approach 1: Reactive LOD (Original Design)
The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
**The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
### Approach 2: Static LOD with CSS (Current Implementation)
The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
**This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
## The GPU Texture Bottleneck
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
### Traditional Assumption
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
### Actual Browser Behavior
When all nodes are children of a single transformed parent:
1. The browser creates one large GPU texture for the entire node graph
2. The texture dimensions are determined by the bounding box of all content
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
## Two Distinct Performance Concerns
The analysis reveals two often-conflated performance considerations that should be understood separately:
### 1. Rendering Performance
**Question:** How fast can the browser paint and composite the node graph during interactions?
**Traditional thinking:** Show less content → render faster
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
### 2. Memory and Lifecycle Management
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
This is where unmounting widgets might theoretically help:
- Complex widgets (3D viewers, chart renderers) might hold significant memory
- Event listeners and reactive watchers consume resources
- Some widgets might run background processes or animations
However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
## Design Philosophy and Trade-offs
The current CSS-based approach makes several deliberate trade-offs:
### What We Optimize For
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
3. **Simple widget development** - Widget authors don't need to implement LOD logic
4. **Reliable state preservation** - Widgets never lose state from unmounting
### What We Accept
1. **Higher baseline memory usage** - All widgets remain mounted
2. **Less granular control** - Widgets can't optimize their own LOD behavior
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
## Open Questions and Future Considerations
### Should widgets have any LOD control?
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
**Current behavior:** Hidden via CSS but still mounted
**Question:** Should such widgets be able to opt into unmounting at distance?
The challenge is that introducing selective unmounting would require:
- Maintaining widget state across mount/unmount cycles
- Accepting the performance cost of remounting when zooming in
- Adding complexity to the widget API
### Could we reduce GPU texture size?
Since texture dimensions are the limiting factor, could we:
- Use multiple compositor layers for different regions (chunk the transformpane)?
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
### Is there a hybrid approach?
Could we identify specific threshold scenarios where reactive LOD makes sense?
- When node count is low (< 50 nodes)
- For specifically registered "expensive" widgets
- At extreme zoom levels only
## Implementation Guidelines
Given the current architecture, here's how to work within the system:
### For Widget Developers
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
3. **Minimize background processing** - Assume your widget is always running
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
### For System Architects
1. **Monitor GPU memory usage** - The single texture approach has memory implications
2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size
3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns
4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation
## Conclusion
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.

View File

@@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => {
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const isResizingVueNodes = ref(false)
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
return {
layoutStore: {
isDraggingVueNodes,
isResizingVueNodes,
getNodeLayoutRef,
setSource
}

View File

@@ -63,6 +63,9 @@ export function useNodePointerInteractions(
function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
// Don't activate drag while resizing
if (layoutStore.isResizingVueNodes.value) return
const nodeId = toValue(nodeIdRef)
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {
@@ -124,6 +127,10 @@ export function useNodePointerInteractions(
safeDragEnd(event)
return
}
// Skip selection handling for right-click (button 2) - context menu handles its own selection
if (event.button === 2) return
const multiSelect = isMultiSelectKey(event)
const nodeId = toValue(nodeIdRef)

View File

@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
height: Math.max(0, height)
}
let updates = updatesByType.get(elementType)
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
}
}
// Set source to Vue before processing DOM-driven updates
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setSource(LayoutSource.DOM)
// Flush per-type
for (const [type, updates] of updatesByType) {

View File

@@ -1,104 +0,0 @@
import type { Point, Size } from '@/renderer/core/layout/types'
export type ResizeHandleDirection = {
horizontal: 'left' | 'right'
vertical: 'top' | 'bottom'
}
function applyHandleDelta(
startSize: Size,
delta: Point,
handle: ResizeHandleDirection
): Size {
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
return {
width: startSize.width + delta.x * horizontalMultiplier,
height: startSize.height + delta.y * verticalMultiplier
}
}
function computeAdjustedPosition(
startPosition: Point,
startSize: Size,
nextSize: Size,
handle: ResizeHandleDirection
): Point {
const widthDelta = startSize.width - nextSize.width
const heightDelta = startSize.height - nextSize.height
return {
x:
handle.horizontal === 'left'
? startPosition.x + widthDelta
: startPosition.x,
y:
handle.vertical === 'top'
? startPosition.y + heightDelta
: startPosition.y
}
}
/**
* Computes the resulting size and position of a node given pointer movement
* and handle orientation.
*/
export function computeResizeOutcome({
startSize,
startPosition,
delta,
handle,
snapFn
}: {
startSize: Size
startPosition: Point
delta: Point
handle: ResizeHandleDirection
snapFn?: (size: Size) => Size
}): { size: Size; position: Point } {
const resized = applyHandleDelta(startSize, delta, handle)
const snapped = snapFn?.(resized) ?? resized
const position = computeAdjustedPosition(
startPosition,
startSize,
snapped,
handle
)
return {
size: snapped,
position
}
}
export function createResizeSession(config: {
startSize: Size
startPosition: Point
handle: ResizeHandleDirection
}) {
const startSize = { ...config.startSize }
const startPosition = { ...config.startPosition }
const handle = config.handle
return (delta: Point, snapFn?: (size: Size) => Size) =>
computeResizeOutcome({
startSize,
startPosition,
handle,
delta,
snapFn
})
}
export function toCanvasDelta(
startPointer: Point,
currentPointer: Point,
scale: number
): Point {
const safeScale = scale === 0 ? 1 : scale
return {
x: (currentPointer.x - startPointer.x) / safeScale,
y: (currentPointer.y - startPointer.y) / safeScale
}
}

View File

@@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { Point, Size } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface ResizeCallbackPayload {
size: Size
position: Point
}
/**
* Composable for node resizing functionality
* Composable for node resizing functionality (bottom-right corner only)
*
* Provides resize handle interaction that integrates with the layout system.
* Handles pointer capture, coordinate calculations, and size constraints.
@@ -27,16 +24,7 @@ export function useNodeResize(
const isResizing = ref(false)
const resizeStartPointer = ref<Point | null>(null)
const resizeSession = ref<
| ((
delta: Point,
snapFn?: (size: Size) => Size
) => {
size: Size
position: Point
})
| null
>(null)
const resizeStartSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
@@ -44,11 +32,7 @@ export function useNodeResize(
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (
event: PointerEvent,
handle: ResizeHandleDirection,
startPosition: Point
) => {
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -72,47 +56,49 @@ export function useNodeResize(
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
// Mark as resizing to prevent drag from activating
layoutStore.isResizingVueNodes.value = true
isResizing.value = true
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
resizeSession.value = createResizeSession({
startSize,
startPosition: { ...startPosition },
handle
})
resizeStartSize.value = startSize
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPointer.value ||
!resizeSession.value
)
!resizeStartSize.value
) {
return
}
const startPointer = resizeStartPointer.value
const session = resizeSession.value
const scale = transformState.camera.z
const deltaX =
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
const deltaY =
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
const delta = toCanvasDelta(
startPointer,
{ x: moveEvent.clientX, y: moveEvent.clientY },
transformState.camera.z
)
let newSize: Size = {
width: resizeStartSize.value.width + deltaX,
height: resizeStartSize.value.height + deltaY
}
// Apply snap if shift is held
if (shouldSnap(moveEvent)) {
newSize = applySnapToSize(newSize)
}
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
const outcome = session(
delta,
shouldSnap(moveEvent) ? applySnapToSize : undefined
)
resizeCallback(outcome, nodeElement)
resizeCallback({ size: newSize }, nodeElement)
}
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeSession.value = null
resizeStartSize.value = null
// Stop tracking shift key state
stopShiftSync()

View File

@@ -1,6 +1,5 @@
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createBounds } from '@/lib/litegraph/src/measure'
import { useSettingStore } from '@/platform/settings/settingStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -9,6 +8,7 @@ import type { NodeBoundsUpdate } from '@/renderer/core/layout/types'
import { app as comfyApp } from '@/scripts/app'
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const SCALE_FACTOR = 1.2
@@ -59,25 +59,22 @@ export function ensureCorrectLayoutScale(
const [oldX, oldY] = lgNode.pos
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
const relativeX = oldX - originX
const relativeY = adjustedY - originY
const relativeY = oldY - originY
const scaledX = originX + relativeX * scaleFactor
const scaledY = originY + relativeY * scaleFactor
const scaledWidth = lgNode.width * scaleFactor
const scaledHeight =
lgNode.height * scaleFactor -
(needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT) // Litegraph Position further down
const scaledHeight = needsUpscale
? lgNode.size[1] * scaleFactor + LiteGraph.NODE_TITLE_HEIGHT
: (lgNode.size[1] - LiteGraph.NODE_TITLE_HEIGHT) * scaleFactor
// Directly update LiteGraph node to ensure immediate consistency
// Dont need to reference vue directly because the pos and dims are already in yjs
lgNode.pos[0] = scaledX
lgNode.pos[1] = finalY
lgNode.pos[1] = scaledY
lgNode.size[0] = scaledWidth
lgNode.size[1] = scaledHeight
@@ -87,7 +84,7 @@ export function ensureCorrectLayoutScale(
nodeId: String(lgNode.id),
bounds: {
x: scaledX,
y: finalY,
y: scaledY,
width: scaledWidth,
height: scaledHeight
}
@@ -147,10 +144,8 @@ export function ensureCorrectLayoutScale(
const [oldX, oldY] = group.pos
const [oldWidth, oldHeight] = group.size
const adjustedY = oldY - (needsUpscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
const relativeX = oldX - originX
const relativeY = adjustedY - originY
const relativeY = oldY - originY
const scaledX = originX + relativeX * scaleFactor
const scaledY = originY + relativeY * scaleFactor
@@ -158,9 +153,7 @@ export function ensureCorrectLayoutScale(
const scaledWidth = oldWidth * scaleFactor
const scaledHeight = oldHeight * scaleFactor
const finalY = scaledY + (needsUpscale ? 0 : LiteGraph.NODE_TITLE_HEIGHT)
group.pos = [scaledX, finalY]
group.pos = [scaledX, scaledY]
group.size = [scaledWidth, scaledHeight]
})

View File

@@ -1,4 +1,4 @@
import { computed, toValue } from 'vue'
import { computed, onUnmounted, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -17,6 +17,11 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
// Get the customRef for this node (shared write access)
const layoutRef = layoutStore.getNodeLayoutRef(nodeId)
// Clean up refs and triggers when Vue component unmounts
onUnmounted(() => {
layoutStore.cleanupNodeRef(nodeId)
})
// Computed properties for easy access
const position = computed(() => {
const layout = layoutRef.value

View File

@@ -1,34 +0,0 @@
/**
* Level of Detail (LOD) composable for Vue-based node rendering
*
* Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom threshold based on DPR
* to determine how much detail to render for each node component.
* Default minFontSize = 8px
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
**/
import { useDevicePixelRatio } from '@vueuse/core'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
interface Camera {
z: number // zoom level
}
export function useLOD(camera: Camera) {
const isLOD = computed(() => {
const { pixelRatio } = useDevicePixelRatio()
const baseFontSize = 14
const dprAdjustment = Math.sqrt(pixelRatio.value)
const settingStore = useSettingStore()
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
const threshold =
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
return camera.z < threshold
})
return { isLOD }
}

View File

@@ -1,14 +1,15 @@
<template>
<div class="w-full">
<WidgetSelect v-model="modelValue" :widget />
<div class="my-4">
<AudioPreviewPlayer
:audio-url="audioUrlFromWidget"
:readonly="readonly"
:hide-when-empty="isOutputNodeRef"
:show-options-button="true"
/>
</div>
<div
class="w-full col-span-2 widget-expands grid grid-cols-[minmax(80px,max-content)_minmax(125px,auto)] gap-y-3 p-3"
>
<WidgetSelect v-model="modelValue" :widget class="col-span-2" />
<AudioPreviewPlayer
class="col-span-2"
:audio-url="audioUrlFromWidget"
:readonly="readonly"
:hide-when-empty="isOutputNodeRef"
:show-options-button="true"
/>
</div>
</template>

View File

@@ -81,6 +81,8 @@ const buttonTooltip = computed(() => {
size="small"
variant="outlined"
:step="stepValue"
:min-fraction-digits="precision"
:max-fraction-digits="precision"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'grow text-xs')"
:aria-label="widget.name"

View File

@@ -5,7 +5,7 @@
>
<!-- Display mode: Rendered markdown -->
<div
class="comfy-markdown-content lod-toggle size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
:class="isEditing === false ? 'visible' : 'invisible'"
v-html="renderedHtml"
/>
@@ -27,7 +27,6 @@
@click.stop
@keydown.stop
/>
<LODFallback />
</div>
</template>
@@ -38,8 +37,6 @@ import { computed, nextTick, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import LODFallback from '../../components/LODFallback.vue'
const { widget } = defineProps<{
widget: SimplifiedWidget<string>
}>()

View File

@@ -78,7 +78,6 @@
@ended="playback.onPlaybackEnded"
@loadedmetadata="playback.onMetadataLoaded"
/>
<LODFallback />
</div>
</template>
@@ -91,7 +90,6 @@ import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'

View File

@@ -3,6 +3,7 @@
<Select
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"

View File

@@ -3,9 +3,7 @@
<Textarea
v-model="modelValue"
v-bind="filteredProps"
:class="
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
"
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:placeholder="placeholder || widget.name || ''"
:aria-label="widget.name"
:readonly="widget.options?.read_only"
@@ -17,7 +15,6 @@
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<LODFallback />
</div>
</template>
@@ -32,7 +29,6 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import LODFallback from '../../components/LODFallback.vue'
import { WidgetInputBaseClass } from './layout'
const { widget, placeholder = '' } = defineProps<{

View File

@@ -132,7 +132,6 @@
</template>
</TieredMenu>
</div>
<LODFallback />
</div>
</template>
@@ -143,7 +142,6 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'

View File

@@ -3,8 +3,6 @@ import { noop } from 'es-toolkit'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import LODFallback from '../../../components/LODFallback.vue'
defineProps<{
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name' | 'label'>
}>()
@@ -17,23 +15,21 @@ defineProps<{
<div class="relative flex h-full min-w-0 items-center">
<p
v-if="widget.name"
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
class="flex-1 truncate text-xs font-normal text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</p>
<LODFallback />
</div>
<!-- basis-full grow -->
<div class="relative min-w-0 flex-1">
<div
class="lod-toggle cursor-default min-w-0"
class="cursor-default min-w-0"
@pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"
>
<slot />
</div>
<LODFallback />
</div>
</div>
</template>

View File

@@ -20,13 +20,17 @@ import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingClo
const isFileProtocol = window.location.protocol === 'file:'
// Determine base path for the router
// - Electron: always root
// - Web: rely on Vite's BASE_URL (configured via vite.config `base`)
/**
* Determine base path for the router.
* - Electron: always root
* - Cloud: use Vite's BASE_URL (configured at build time)
* - Standard web (including reverse proxy subpaths): use window.location.pathname
* to support deployments like http://mysite.com/ComfyUI/
*/
function getBasePath(): string {
if (isElectron()) return '/'
// Vite injects BASE_URL at build/dev time; default to '/'
return import.meta.env?.BASE_URL || '/'
if (isCloud) return import.meta.env?.BASE_URL || '/'
return window.location.pathname
}
const basePath = getBasePath()

View File

@@ -1057,7 +1057,13 @@ export class ComfyApp {
}
let reset_invalid_values = false
if (!graphData) {
// Use explicit validation instead of falsy check to avoid replacing
// valid but falsy values (empty objects, 0, false, etc.)
if (
!graphData ||
typeof graphData !== 'object' ||
Array.isArray(graphData)
) {
graphData = defaultGraph
reset_invalid_values = true
}
@@ -1432,6 +1438,38 @@ export class ComfyApp {
this.loadTemplateData({ templates })
}
// Check workflow first - it should take priority over parameters
// when both are present (e.g., in ComfyUI-generated PNGs)
if (workflow) {
let workflowObj: ComfyWorkflowJSON | undefined = undefined
try {
workflowObj =
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
// Only load workflow if parsing succeeded AND validation passed
if (
workflowObj &&
typeof workflowObj === 'object' &&
!Array.isArray(workflowObj)
) {
await this.loadGraphData(workflowObj, true, true, fileName, {
openSource
})
return
} else {
console.error(
'Invalid workflow structure, trying parameters fallback'
)
this.showErrorOnFileLoad(file)
}
} catch (err) {
console.error('Failed to parse workflow:', err)
this.showErrorOnFileLoad(file)
// Fall through to check parameters as fallback
}
}
// Use parameters as fallback when no workflow exists
if (parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
@@ -1444,15 +1482,6 @@ export class ComfyApp {
return
}
if (workflow) {
const workflowObj =
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
await this.loadGraphData(workflowObj, true, true, fileName, {
openSource
})
return
}
if (prompt) {
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
this.loadApiJson(promptObj, fileName)

View File

@@ -214,7 +214,9 @@ export const useLitegraphService = () => {
*/
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const { name, is_list } = output
// TODO: Fix the typing at the node spec level
const type = output.type === 'COMFY_MATCHTYPE_V3' ? '*' : output.type
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`

View File

@@ -206,19 +206,25 @@ const init = () => {
}
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const sidebarTabStore = useSidebarTabStore()
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
queuePendingTaskCountStore.update(e)
await Promise.all([
queueStore.update(),
assetsStore.updateHistory() // Update history assets when status changes
])
await queueStore.update()
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets') {
await assetsStore.updateHistory()
}
}
const onExecutionSuccess = async () => {
await Promise.all([
queueStore.update(),
assetsStore.updateHistory() // Update history assets on execution success
])
await queueStore.update()
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets') {
await assetsStore.updateHistory()
}
}
const reconnectingMessage: ToastMessageOptions = {

View File

@@ -3,6 +3,14 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
* Utility functions for handling workbench events
*/
/**
* Check if there is selected text in the document.
*/
function hasTextSelection(): boolean {
const selection = window.getSelection()
return selection !== null && selection.toString().trim().length > 0
}
/**
* Used by clipboard handlers to determine if copy/paste events should be
* intercepted for graph operations vs. allowing default browser behavior
@@ -12,7 +20,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
* @returns true if copy paste events will be handled by target
*/
export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
return (
const isTextInput =
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement &&
![
@@ -26,7 +34,6 @@ export function shouldIgnoreCopyPaste(target: EventTarget | null): boolean {
'reset',
'search',
'submit'
].includes(target.type)) ||
useCanvasStore().linearMode
)
].includes(target.type))
return isTextInput || useCanvasStore().linearMode || hasTextSelection()
}

View File

@@ -0,0 +1,37 @@
import { unref } from 'vue'
import type { MaybeRef } from 'vue'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
export type NodeDefLookup = Record<string, ComfyNodeDefImpl | undefined>
const isNodeMissingDefinition = (
node: LGraphNode,
nodeDefsByName: NodeDefLookup
) => {
const nodeName = node?.type
if (!nodeName) return false
return !nodeDefsByName[nodeName]
}
export const collectMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
): LGraphNode[] => {
if (!graph) return []
const lookup = unref(nodeDefsByName)
return collectAllNodes(graph, (node) => isNodeMissingDefinition(node, lookup))
}
export const graphHasMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
) => {
return collectMissingNodes(graph, nodeDefsByName).length > 0
}

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
/**
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
*/
function encodeClipboardData(data: string): string {
return btoa(
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
)
}
/**
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
*/
function decodeClipboardData(base64: string): string {
const binaryString = atob(base64)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
it('should handle ASCII-only strings', () => {
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Chinese characters in localized_name', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Japanese characters', () => {
const original = '{"localized_name":"画像を読み込む"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Korean characters', () => {
const original = '{"localized_name":"이미지 불러오기"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle mixed ASCII and Unicode characters', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle emoji characters', () => {
const original = '{"title":"Test Node 🎨🖼️"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle empty string', () => {
const original = ''
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle complex node data with multiple Unicode fields', () => {
const original = JSON.stringify({
nodes: [
{
id: 1,
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
}
],
groups: [{ title: '预处理组 🔧' }],
links: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
it('should produce valid base64 output', () => {
const original = '{"localized_name":"中文测试"}'
const encoded = encodeClipboardData(original)
// Base64 should only contain valid characters
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
})
it('should fail with plain btoa for non-Latin1 characters', () => {
const original = '{"localized_name":"图像"}'
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
})
})

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