Compare commits

...

38 Commits

Author SHA1 Message Date
Luke Mino-Altherr
8d90716662 [demo] Add feature flag support to CloudRunButtonWrapper
- Wrap SubscribeToRunButton with FeatureFlaggedRunButton for cloud users
- Enable experimental button variants for cloud subscription flow
- Maintains existing behavior: ComfyQueueButton (with subscription) or SubscribeToRunButton (without)
2025-12-04 18:04:42 -08:00
Luke Mino-Altherr
0b0af89321 [demo] PostHog feature flag integration for Run button experiment
- Add FeatureFlaggedRunButton wrapper component
- Add ExperimentalRunButton with JSON-driven styling
- Extend useFeatureFlags to support remoteConfig and variant payloads
- Support 4 experimental button variants (bold-gradient, animated, playful, minimal)
- Add documentation for feature flag payload structure

NOTE: This is a DEMO and should NOT be merged
2025-12-04 16:12:52 -08:00
Luke Mino-Altherr
d314172b98 [feat] Add remote config support for model upload and asset update feature flags (#7143)
Feature flags for model upload button and asset update options now check
remote config from `/api/features` first, falling back to websocket
feature flags.

- **What**: Added `model_upload_button_enabled` and
`asset_update_options_enabled` to `RemoteConfig` type
- **What**: Updated feature flag getters to prioritize remote config
over websocket flags
- **Why**: Enables dynamic feature control without requiring websocket
connection, consistent with other feature flags pattern

- Pattern consistency with other remote config feature flags
- Proper fallback behavior when remote config is unavailable

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7143-feat-Add-remote-config-support-for-model-upload-and-asset-update-feature-flags-2bf6d73d3650819cb364f0ab69d77dd0)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 11:10:35 -08:00
Comfy Org PR Bot
6c3408592e [backport cloud/1.33] cloud: increase feature flag polling interval to 10min (from 30s) (#7111)
Backport of #7100 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7111-backport-cloud-1-33-cloud-increase-feature-flag-polling-interval-to-10min-from-30s-2be6d73d3650817ea746fa49eb896a2d)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-02 19:28:06 -07:00
Comfy Org PR Bot
b6632443dc [backport cloud/1.33] fix: normalize path separators in comfyAPIPlugin for Windows compatibility (#7090)
Backport of #7087 to `cloud/1.33`

Automatically created by backport workflow.

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

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-02 19:27:41 -07:00
Comfy Org PR Bot
c8a1df3a05 [backport cloud/1.33] feat(api-nodes-pricing): add prices for Kling O1 video model (#7079)
Backport of #7077 to `cloud/1.33`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7071-backport-cloud-1-33-fix-Prevent-drag-activation-during-Vue-node-resize-2bc6d73d36508129b6d4ee43e79de500)
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:35 -08:00
Christian Byrne
ffa55cb92b [backport cloud/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7068)
## Summary
- Backport of #7063 to cloud/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-7068-backport-cloud-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508149bd85c3ae534387b6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-30 20:20:35 -08:00
Comfy Org PR Bot
3856e0deea [backport cloud/1.33] fix: loader node widget value shows placeholder instead of filename on cloud (#7046)
Backport of #7005 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7046-backport-cloud-1-33-fix-loader-node-widget-value-shows-placeholder-instead-of-filename-2bb6d73d365081bd9531c08bbaeb8634)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:34 -07:00
Comfy Org PR Bot
7b589b5502 [backport cloud/1.33] mark vue nodes menu toggle with beta tag (#7052)
Backport of #7047 to `cloud/1.33`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-29 15:09:55 -07:00
Comfy Org PR Bot
22aea29a0d [backport cloud/1.33] [feat] Show "Finished in" duration for completed jobs in cloud (#7013)
Backport of #6895 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7013-backport-cloud-1-33-feat-Show-Finished-in-duration-for-completed-jobs-in-cloud-2b86d73d365081419f90ee82f5e45253)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-27 18:31:20 -07:00
Comfy Org PR Bot
637c1995b4 [backport cloud/1.33] [fix] Re-encode cloud-subscription video to VP9 for Safari compatibility (#7012)
Backport of #7006 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7012-backport-cloud-1-33-fix-Re-encode-cloud-subscription-video-to-VP9-for-Safari-compatib-2b86d73d365081a0be8ac57b8afce8a8)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 17:56:57 -07:00
Comfy Org PR Bot
550ca0c911 [backport cloud/1.33] Remove app.graph usage from widgetInput code (#7011)
Backport of #7008 to `cloud/1.33`

Automatically created by backport workflow.

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

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

Automatically created by backport workflow.

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

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

- cherry-picked 29dbfa3f
- accepted upstream snapshot updates (only zoomed-in ctrl+shift PNG
conflicted)

## Testing
- pnpm typecheck

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

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

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6983-backport-cloud-1-33-fix-remove-LOD-from-vue-nodes-2b86d73d36508119bafbc5a5a6a5ad42)
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:50 -07:00
Christian Byrne
31d842217b [backport cloud/1.33] fix: don't use registry when only checking for presence of missing nodes (#6972)
## Summary
Backport of #6965 onto cloud/1.33 (clean cherry-pick of 83f04490b).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6972-backport-cloud-1-33-fix-don-t-use-registry-when-only-checking-for-presence-of-missing--2b86d73d36508150af46da84e754df3a)
by [Unito](https://www.unito.io)
2025-11-26 17:50:40 -07:00
Comfy Org PR Bot
3dd3e26003 [backport cloud/1.33] feat: open template via URL in linear mode (#6968)
Backport of #6945 to `cloud/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6968-backport-cloud-1-33-feat-open-template-via-URL-in-linear-mode-2b76d73d365081c4b5d5c0727106fc29)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-26 17:32:19 -07:00
Alexander Brown
e6332046b0 BYOM: Model Import Wizard (#6949)
## Summary

Design alignment for the model import wizard.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6949-BYOM-Model-Import-Wizard-2b76d73d365081a48632c40430e05c93)
by [Unito](https://www.unito.io)
2025-11-25 19:19:16 -08:00
AustinMroz
5fa76e23d9 Reduce width of run button dock zone (#6925)
Tiny PR to make the docking area line up with the new size of the
actionbar after #6723
<img width="550" height="106" alt="image"
src="https://github.com/user-attachments/assets/911d510e-351f-484f-807a-17f5428dea79"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6925-Reduce-width-of-run-button-dock-zone-2b66d73d36508137aa0cc18178172264)
by [Unito](https://www.unito.io)
2025-11-25 18:19:33 -07:00
Simula_r
fcfb5437a9 feat: mobile breakpoint for vue nodes banner (#6942)
## Summary

Add mobile breakpoint to vue nodes banner

## Changes

- **What**: main.json and vue nodes banner

## Screenshots (if applicable)

<img width="500" height="615" alt="image"
src="https://github.com/user-attachments/assets/fd8cc621-c335-41c9-bbee-4ec0ae04b226"
/>
<img width="980" height="615" alt="image"
src="https://github.com/user-attachments/assets/30e17fc2-fc91-44a3-b9f0-85d5146e861b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6942-feat-mobile-breakpoint-for-vue-nodes-banner-2b66d73d36508139b69afd7e7134b72e)
by [Unito](https://www.unito.io)
2025-11-25 18:18:01 -07:00
Comfy Org PR Bot
5ff3a0ed52 1.33.9 (#6941)
Patch version increment to 1.33.9

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-25 16:59:12 -07:00
Christian Byrne
5fa0295ff5 feat: update About panel (system stats and version info) to work on cloud (#6940)
## Summary

Updates the About Panel in the settings to work with the cloud-specific
`GET /system_stats` response schema.

| Before | After |
| ------ | ----- |
| <img width="1922" height="1436" alt="Selection_2392"
src="https://github.com/user-attachments/assets/3b97bf38-7eeb-4f46-9c59-eb681f5d7401"
/> | <img width="1922" height="1436" alt="Selection_2391"
src="https://github.com/user-attachments/assets/1d30e604-654a-4d48-ba05-4cac3b54c2ba"
/> |

## Screenshots (if applicable)

OSS version stays the same:

<img width="1922" height="1436" alt="Selection_2393"
src="https://github.com/user-attachments/assets/40e1eeeb-fc5a-4ad0-b37f-dc5d0374901e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6940-feat-update-About-panel-system-stats-and-version-info-to-work-on-cloud-2b66d73d365081f69b6fedfe9507ba92)
by [Unito](https://www.unito.io)
2025-11-25 16:34:34 -07:00
Christian Byrne
1348a0934a feat: update node search result rankings with 2025 usage data (#6939)
Updates the usage data for nodes to improve the search result algorithm.
Old data was from 2024. New data is from Nov 2025 and comes from the
search result interactions only (rather than workflow membership). Old
data is normalized against new data's scale and kept in fields where it
wouldn't have been otherwise overwritten.

Here's a helpful visualization of the before and after for the ranking:
https://www.diffchecker.com/Aa5XTN7F/

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6939-feat-update-node-search-result-rankings-with-2025-usage-data-2b66d73d3650818f9bb5d8765f7f7eaa)
by [Unito](https://www.unito.io)
2025-11-25 16:23:21 -07:00
Christian Byrne
01f8e77251 fix: backport workflow fails with PR title has double quotes (#6934)
Fixes issue when PR title or merge commit has double quotes (e.g.,
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19684310613/job/56385939600).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6934-fix-backport-workflow-fails-with-PR-title-has-double-quotes-2b66d73d3650815992dadb6142676754)
by [Unito](https://www.unito.io)
2025-11-25 15:04:31 -07:00
Benjamin Lu
31c03b669e Bump desktop-ui to 0.0.4 (#6933)
This contains a change to clarify to what is the base path issue after
restrictions were tightened.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6933-Bump-desktop-ui-to-0-0-4-2b66d73d3650816e9f3bed7202455ddb)
by [Unito](https://www.unito.io)
2025-11-25 13:46:21 -08:00
Alexander Piskun
c9da19b5b5 feat(api-nodes-pricing): add prices for Veo3FirstLastFrameNode (#6920)
## Summary

Price badges for this PR:
https://github.com/comfyanonymous/ComfyUI/pull/10878

If we can include this in an upcoming release, that would be absolutely
great.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6920-feat-api-nodes-pricing-add-prices-for-Veo3FirstLastFrameNode-2b66d73d365081bdb49be98d953a7a0b)
by [Unito](https://www.unito.io)
2025-11-25 11:56:39 -08:00
Alexander Piskun
10222860eb feat(api-nodes-pricing): add prices for Flux2ProImageNode (#6921)
## Summary

Price badges for https://github.com/comfyanonymous/ComfyUI/pull/10880

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6921-feat-api-nodes-pricing-add-prices-for-Flux2ProImageNode-2b66d73d365081f2b269c77df7ef93d6)
by [Unito](https://www.unito.io)
2025-11-25 11:56:10 -08:00
Benjamin Lu
4597b7e600 [feat] Improve queue job item UX based on design feedback (#6893)
## Summary
- Running jobs now show cancel button at all times (always visible, not
just on hover)
- Cancel/delete buttons use destructive red styling by default with
hover state
- Changed pending job icon from clock to loader-circle with spin
animation
- Fixed icon buttons to be square (size-6) instead of rectangular
- Added TODO comment for future declarative button config system
- Pending hint ("Job added to queue") now shows only once per entry and
no longer resets when other jobs update
- Spinner animation now applies only to the pending loader icon;
completed/check icons no longer spin
- Queue overlay hover/active state also triggers when hovering the top
menu bar so controls stay visible

## Design Spec

https://www.notion.so/comfy-org/Design-Queue-Dialog-Job-Ordering-and-Cancel-Button-Visibility-2b46d73d365081748a43d5cc9fbe2639

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6893-feat-Improve-queue-job-item-UX-based-on-design-feedback-2b56d73d365081a2bc7ef6f6fea1c739)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-25 11:23:39 -08:00
AustinMroz
6782d04f00 Support display_name on frontend (#6922)
The v3 schema allows defining a `display_name` on inputs, but this was
previously ignored on the frontend. It is now used to designate a
default value for the label of a widget or input.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6922-Support-display_name-on-frontend-2b66d73d365081d992cbea07abc27a0f)
by [Unito](https://www.unito.io)
2025-11-25 10:36:14 -08:00
Benjamin Lu
4bb5c12fac Add cloud backport tagging workflow (#6896)
## Summary
- add workflow to tag merged backport-labeled PRs into cloud/* with
cloud/v<package.json version>
- validate branch/version alignment and skip when tag already exists
- use .nvmrc (Node 24) for setup-node

## Question
- This workflow expects the version bump to already be in the PR when
you merge, as it fails if the tag already exists to keep immutability.
Should we autobump in this case?
2025-11-24 22:56:33 -08:00
Alexander Brown
8b5cfe7e55 Lint: Adding more checks for non internationalized strings (#5625)
## Summary

Catch more user visible (or audible) text that isn't
internationalizable.

## Changes

- **What**: Linter now checks other attributes for raw text.

## Review Focus

What other properties have leaked English text to non-English locales
that aren't in here?
2025-11-24 21:55:47 -08:00
Alexander Brown
135169003f Devex: Remove Importmap plugin (#6899)
## Summary

See [this
page](https://www.notion.so/comfy-org/Remove-importmap-and-replace-with-better-solution-if-it-exists-2ab6d73d3650801d83afe006fa0d9929?source=copy_link).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6899-Devex-Remove-Importmap-plugin-2b66d73d365081b28167c9ae70100092)
by [Unito](https://www.unito.io)
2025-11-24 20:39:46 -08:00
Alexander Brown
d58a464c9c Style: Widget spacing and Markdown padding (#6902)
## Summary

Less padding, looks nice.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6902-Style-Widget-spacing-and-Markdown-padding-2b66d73d365081cca409dbabc7b883bb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-24 18:31:21 -08:00
Alexander Brown
e54b972550 Chore: Update CODEOWNERS (#6901)
## Summary

Add Global Owners and a team, update/remove references to some cherished
former colleagues.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6901-Chore-Update-CODEOWNERS-2b66d73d365081f8b687ef678567b30a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-24 17:34:41 -08:00
Alexander Brown
9bd63dbe6a Cleanup: Consistent use of Nodes 2.0 in user facing strings. (#6898)
https://github.com/Comfy-Org/ComfyUI_frontend/issues/6888

## Summary

Did a quick pass to find user facing strings. Not sure if this gets them
all.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6898-Cleanup-Consistent-use-of-Nodes-2-0-in-user-facing-strings-2b66d73d36508124aee2f5f8373a4b60)
by [Unito](https://www.unito.io)
2025-11-24 17:26:16 -08:00
Johnpaul Chiwetelu
a21c813d11 Style button widgets (#6900)
This pull request introduces improvements to widget customization and UI
consistency in the application. The most notable changes are the
addition of support for icon classes in widget options, updates to
button rendering logic, and enhanced visual consistency for button
components.

Widget customization enhancements:
* Added an optional `iconClass` property to the `IWidgetOptions`
interface in `widgets.ts`, allowing widgets to specify custom icons.

UI and rendering updates:
* Updated `WidgetButton.vue` to render the widget label and, if
provided, an icon using the new `iconClass` option. Also standardized
button styling and label usage.
* Improved button styling in `WidgetRecordAudio.vue` for better visual
consistency with other components.
<img width="662" height="534" alt="Screenshot 2025-11-25 at 01 36 45"
src="https://github.com/user-attachments/assets/43bbe226-07fd-48be-9b98-78b08a726b1b"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6900-Style-button-widgets-2b66d73d3650818ebeadd9315a47ba0f)
by [Unito](https://www.unito.io)
2025-11-25 01:12:30 +00:00
140 changed files with 5444 additions and 3893 deletions

View File

@@ -0,0 +1,69 @@
---
name: Cloud Backport Tag
on:
pull_request:
types: ['closed']
branches: [cloud/*]
jobs:
create-tag:
if: >
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'backport')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
- name: Create tag for cloud backport
id: tag
run: |
set -euo pipefail
BRANCH="${{ github.event.pull_request.base.ref }}"
if [[ ! "$BRANCH" =~ ^cloud/([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Base branch '$BRANCH' is not a cloud/x.y branch"
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
VERSION=$(node -p "require('./package.json').version")
if [[ "$VERSION" =~ ^${MAJOR}\.${MINOR}\.([0-9]+)(-.+)?$ ]]; then
PATCH="${BASH_REMATCH[1]}"
SUFFIX="${BASH_REMATCH[2]:-}"
else
echo "::error::Version '${VERSION}' does not match cloud branch '${BRANCH}'"
exit 1
fi
TAG="cloud/v${VERSION}"
if git ls-remote --tags origin "${TAG}" | grep -Fq "refs/tags/${TAG}"; then
echo "::notice::Tag ${TAG} already exists; skipping"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
exit 0
fi
git tag "${TAG}" "${{ github.event.pull_request.merge_commit_sha }}"
git push origin "${TAG}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
{
echo "Created tag: ${TAG}"
echo "Branch: ${BRANCH}"
echo "Version: ${VERSION}"
echo "Commit: ${{ github.event.pull_request.merge_commit_sha }}"
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -236,8 +236,8 @@ jobs:
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_TITLE="${{ github.event.pull_request.title }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
for target in ${{ steps.filter-targets.outputs.pending-targets }}; do
@@ -326,8 +326,8 @@ jobs:
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
else
PR_TITLE="${{ github.event.pull_request.title }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
fi
for backport in ${{ steps.backport.outputs.success }}; do
@@ -364,9 +364,9 @@ jobs:
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
MERGE_COMMIT=$(echo "$PR_DATA" | jq -r '.mergeCommit.oid')
else
PR_NUMBER="${{ github.event.pull_request.number }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
PR_NUMBER=$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
for failure in ${{ steps.backport.outputs.failed }}; do

View File

@@ -64,7 +64,6 @@ const config: StorybookConfig = {
deep: true,
extensions: ['vue']
})
// Note: Explicitly NOT including generateImportMapPlugin to avoid externalization
],
server: {
allowedHosts: true

View File

@@ -1,8 +1,11 @@
# Global Ownership
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @webfiltered
/src/stores/electronDownloadStore.ts @webfiltered
/src/extensions/core/electronAdapter.ts @webfiltered
/vite.electron.config.mts @webfiltered
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
# Common UI Components
/src/components/chip/ @viva-jinyi
@@ -31,10 +34,7 @@
/src/components/graph/selectionToolbox/ @Myestery
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88
# Assets
/src/platform/assets/ @arjansingh
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
@@ -53,7 +53,7 @@
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Translations
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
# LLM Instructions (blank on purpose)
.claude/

View File

@@ -0,0 +1,229 @@
# Feature Flags System Explanation
## Overview
The `useFeatureFlag` hook (actually named `useFeatureFlags`) is a Vue 3 composable that provides **reactive access to server-side feature flags** received via WebSocket from the backend. It enables capability negotiation between frontend and backend, allowing the UI to adapt based on what features the server supports.
## Architecture Flow
```
1. Frontend connects via WebSocket
2. Frontend sends client feature flags (first message)
3. Backend responds with server feature flags
4. Frontend stores flags in api.serverFeatureFlags
5. Components use useFeatureFlags() to access flags reactively
```
## Core Implementation
### 1. The `useFeatureFlags` Composable
**Location:** `src/composables/useFeatureFlags.ts`
The composable returns two things:
#### A. Predefined `flags` Object
A reactive object with getter properties for commonly-used feature flags:
```typescript
const { flags } = useFeatureFlags()
// Access predefined flags
flags.supportsPreviewMetadata // boolean | undefined
flags.maxUploadSize // number | undefined
flags.supportsManagerV4 // boolean | undefined
flags.modelUploadButtonEnabled // boolean (checks remoteConfig first)
flags.assetUpdateOptionsEnabled // boolean (checks remoteConfig first)
```
**Key Points:**
- Uses Vue's `reactive()` to make the object reactive
- Each getter calls `api.getServerFeature()` which reads from `api.serverFeatureFlags`
- Some flags (like `modelUploadButtonEnabled`) check `remoteConfig` first (from `/api/features` endpoint) before falling back to WebSocket flags
- Returns a `readonly()` wrapper to prevent external mutation
#### B. Generic `featureFlag` Function
A function that creates a computed ref for any feature flag path:
```typescript
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref for any flag
const myFlag = featureFlag('custom.feature.path', false) // defaultValue is optional
// myFlag is a ComputedRef that updates when serverFeatureFlags changes
```
**Key Points:**
- Accepts any string path (supports dot notation for nested values)
- Returns a `computed()` ref that automatically updates when flags change
- Generic type parameter allows type safety: `featureFlag<boolean>('flag', false)`
### 2. The Underlying API Layer
**Location:** `src/scripts/api.ts`
The `ComfyApi` class manages feature flags:
```typescript
class ComfyApi {
// Stores flags received from backend
serverFeatureFlags: Record<string, unknown> = {}
// Retrieves a flag value using dot notation
getServerFeature<T>(featureName: string, defaultValue?: T): T {
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}
}
```
**How Flags Are Received:**
1. WebSocket connection is established
2. Frontend sends client feature flags as first message
3. Backend responds with a `feature_flags` message type
4. The message handler stores it: `this.serverFeatureFlags = msg.data`
**The `get` Function:**
- Uses `es-toolkit/compat`'s `get` function (lodash-style)
- Supports dot notation: `'extension.manager.supports_v4'` accesses nested objects
- Returns `defaultValue` if the path doesn't exist
### 3. Remote Config Integration
**Location:** `src/platform/remoteConfig/remoteConfig.ts`
Some flags check `remoteConfig` first (loaded from `/api/features` endpoint):
```typescript
// Example from modelUploadButtonEnabled
return (
remoteConfig.value.model_upload_button_enabled ?? // Check remote config first
api.getServerFeature(ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, false) // Fallback
)
```
**Why Two Sources?**
- `remoteConfig`: Fetched via HTTP at app startup, can be updated without WebSocket
- WebSocket flags: Real-time capability negotiation, updated on reconnection
## Usage Patterns
### Pattern 1: Using Predefined Flags
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
// In template
if (flags.supportsPreviewMetadata) {
// Use enhanced preview feature
}
// In script
const maxSize = flags.maxUploadSize ?? 100 * 1024 * 1024 // Default 100MB
```
### Pattern 2: Using Generic featureFlag Function
```typescript
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { featureFlag } = useFeatureFlags()
// Create a reactive computed ref
const customFeature = featureFlag<boolean>('extension.custom.feature', false)
// Use in template (automatically reactive)
// <div v-if="customFeature">New Feature UI</div>
// Use in script
watch(customFeature, (enabled) => {
if (enabled) {
// Feature was enabled
}
})
```
### Pattern 3: Direct API Access (Non-Reactive)
```typescript
import { api } from '@/scripts/api'
// Direct access (not reactive, use sparingly)
if (api.serverSupportsFeature('supports_preview_metadata')) {
// Feature is supported
}
const maxSize = api.getServerFeature('max_upload_size', 100 * 1024 * 1024)
```
## Reactivity Explained
The composable is **reactive** because:
1. **Predefined flags**: Use `reactive()` with getters, so when `api.serverFeatureFlags` changes, Vue's reactivity system detects it
2. **Generic featureFlag**: Returns `computed()`, which automatically tracks `api.getServerFeature()` calls and re-evaluates when flags change
3. **WebSocket updates**: When flags are updated via WebSocket, `api.serverFeatureFlags` is reassigned, triggering reactivity
## Adding New Feature Flags
### Step 1: Add to Enum (if it's a core flag)
```typescript
// In useFeatureFlags.ts
export enum ServerFeatureFlag {
// ... existing flags
MY_NEW_FEATURE = 'my_new_feature'
}
```
### Step 2: Add to flags Object (if commonly used)
```typescript
// In useFeatureFlags.ts flags object
get myNewFeature() {
return api.getServerFeature(ServerFeatureFlag.MY_NEW_FEATURE, false)
}
```
### Step 3: Use in Components
```typescript
const { flags } = useFeatureFlags()
if (flags.myNewFeature) {
// Use the feature
}
```
**OR** use the generic function without modifying the composable:
```typescript
const { featureFlag } = useFeatureFlags()
const myFeature = featureFlag('my_new_feature', false)
```
## Important Notes
1. **Flags are server-driven**: The backend controls which flags are available
2. **Default values**: Always provide sensible defaults when using `getServerFeature()`
3. **Reactivity**: The composable ensures UI updates automatically when flags change (e.g., on WebSocket reconnection)
4. **Type safety**: Use TypeScript generics with `featureFlag<T>()` for type safety
5. **Dot notation**: Feature flags can be nested, use dot notation: `'extension.manager.supports_v4'`
6. **Remote config priority**: Some flags check `remoteConfig` first, then fall back to WebSocket flags
## Testing
See `tests-ui/tests/composables/useFeatureFlags.test.ts` for examples of:
- Mocking `api.getServerFeature()`
- Testing reactive behavior
- Testing default values
- Testing nested paths
## Related Files
- `src/composables/useFeatureFlags.ts` - The main composable
- `src/scripts/api.ts` - API layer with `getServerFeature()` method
- `src/platform/remoteConfig/remoteConfig.ts` - Remote config integration
- `docs/FEATURE_FLAGS.md` - Full system documentation
- `tests-ui/tests/composables/useFeatureFlags.test.ts` - Unit tests

196
FEATURE_FLAG_PAYLOAD.md Normal file
View File

@@ -0,0 +1,196 @@
# Feature Flag Payload Shape
## Feature Flag Key
`demo-run-button-experiment`
## Expected Structure
The feature flag value should be either:
### Control (Original Button)
- `false`
- `null`
- `undefined`
- Not set
When any of these values are present, the original SplitButton will be displayed.
### Experiment (Experimental Button)
An object with the following structure:
```typescript
{
variant: string, // Required: variant name (e.g., "bold-gradient", "animated", "playful", "minimal")
payload?: { // Optional: styling and content overrides
label?: string, // Button text (default: "Run")
icon?: string, // Icon class (default: variant-specific)
backgroundColor?: string, // Background color/class (default: variant-specific)
textColor?: string, // Text color/class (default: variant-specific)
borderRadius?: string, // Border radius class (default: variant-specific)
padding?: string // Padding class (default: "px-4 py-2")
}
}
```
## Example Payloads
### Bold Gradient Variant
```json
{
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"backgroundColor": "transparent",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
```
### Animated Variant
```json
{
"variant": "animated",
"payload": {
"label": "Launch",
"icon": "icon-[lucide--rocket]",
"backgroundColor": "bg-primary-background",
"textColor": "white",
"borderRadius": "rounded-full"
}
}
```
### Playful Variant
```json
{
"variant": "playful",
"payload": {
"label": "Go!",
"icon": "icon-[lucide--sparkles]",
"backgroundColor": "bg-gradient-to-br from-yellow-400 to-orange-500",
"textColor": "white",
"borderRadius": "rounded-2xl"
}
}
```
### Minimal Variant
```json
{
"variant": "minimal",
"payload": {
"label": "Run",
"icon": "icon-[lucide--play]",
"backgroundColor": "bg-white",
"textColor": "text-gray-800",
"borderRadius": "rounded-md"
}
}
```
## Payload Properties
### `variant` (required)
- Type: `string`
- Description: The variant name that determines the base styling and behavior
- Supported values:
- `"bold-gradient"` - Gradient background with animated effect
- `"animated"` - Pulsing animation effect
- `"playful"` - Sparkle effects with playful styling
- `"minimal"` - Clean, minimal design
- Any custom variant name (will use default styling)
### `payload.label` (optional)
- Type: `string`
- Default: `"Run"` (or variant-specific default)
- Description: The text displayed on the button
### `payload.icon` (optional)
- Type: `string`
- Default: Variant-specific icon
- Description: Icon class name (e.g., `"icon-[lucide--play]"`)
- Examples:
- `"icon-[lucide--play]"` - Play icon
- `"icon-[lucide--zap]"` - Lightning bolt
- `"icon-[lucide--rocket]"` - Rocket
- `"icon-[lucide--sparkles]"` - Sparkles
### `payload.backgroundColor` (optional)
- Type: `string`
- Default: Variant-specific background
- Description: Tailwind CSS class or CSS color value for background
- Examples:
- `"bg-primary-background"` - Primary background color
- `"bg-white"` - White background
- `"transparent"` - Transparent (for gradient overlays)
- `"bg-gradient-to-br from-yellow-400 to-orange-500"` - Gradient
### `payload.textColor` (optional)
- Type: `string`
- Default: Variant-specific text color
- Description: Tailwind CSS class or CSS color value for text
- Examples:
- `"white"` - White text (CSS color)
- `"text-white"` - White text (Tailwind class)
- `"text-gray-800"` - Dark gray text
### `payload.borderRadius` (optional)
- Type: `string`
- Default: Variant-specific border radius
- Description: Tailwind CSS border radius class
- Examples:
- `"rounded-md"` - Medium border radius
- `"rounded-xl"` - Extra large border radius
- `"rounded-full"` - Fully rounded (pill shape)
- `"rounded-2xl"` - 2x extra large border radius
### `payload.padding` (optional)
- Type: `string`
- Default: `"px-4 py-2"`
- Description: Tailwind CSS padding classes
- Examples:
- `"px-4 py-2"` - Standard padding
- `"px-6 py-3"` - Larger padding
- `"px-2 py-1"` - Smaller padding
## Backend Integration
The backend should send this feature flag via WebSocket in the `feature_flags` message:
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": {
"variant": "bold-gradient",
"payload": {
"label": "Run",
"icon": "icon-[lucide--zap]",
"textColor": "white",
"borderRadius": "rounded-xl"
}
}
}
}
```
Or to show the control (original button):
```json
{
"type": "feature_flags",
"data": {
"demo-run-button-experiment": false
}
}
```
## Notes
- All `payload` properties are optional - if omitted, variant-specific defaults will be used
- The `variant` property is required when the flag is truthy
- Color values can be either Tailwind classes (e.g., `"text-white"`) or CSS color values (e.g., `"white"`)
- The component will automatically handle both formats

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/desktop-ui",
"version": "0.0.3",
"version": "0.0.4",
"type": "module",
"nx": {
"tags": [

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: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 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: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 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: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 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: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 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: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 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,154 +0,0 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { type HtmlTagDescriptor, type Plugin, normalizePath } from 'vite'
interface ImportMapSource {
name: string
pattern: string | RegExp
entry: string
recursiveDependence?: boolean
override?: Record<string, Partial<ImportMapSource>>
}
const parseDeps = (root: string, pkg: string) => {
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
const content = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(content)
return Object.keys(pkg.dependencies || {})
}
return []
}
/**
* Vite plugin that generates an import map for vendor chunks.
*
* This plugin creates a browser-compatible import map that maps module specifiers
* (like 'vue' or 'primevue') to their actual file locations in the build output.
* This improves module loading in modern browsers and enables better caching.
*
* The plugin:
* 1. Tracks vendor chunks during bundle generation
* 2. Creates mappings between module names and their file paths
* 3. Injects an import map script tag into the HTML head
* 4. Configures manual chunk splitting for vendor libraries
*
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
importMapSources: ImportMapSource[]
): Plugin {
const importMapEntries: Record<string, string> = {}
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
const assetDir = 'assets/lib'
let root: string
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
root = config.root
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
for (const source of importMapSources) {
resolvedImportMapSources.set(source.name, source)
if (source.recursiveDependence) {
const deps = parseDeps(root, source.name)
while (deps.length) {
const dep = deps.shift()!
const depSource = Object.assign({}, source, {
name: dep,
pattern: dep,
...source.override?.[dep]
})
resolvedImportMapSources.set(depSource.name, depSource)
const _deps = parseDeps(root, depSource.name)
deps.unshift(..._deps)
}
}
}
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
}
},
generateBundle(_options) {
for (const [, source] of resolvedImportMapSources) {
if (source.entry) {
const moduleFile = join(source.name, source.entry)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
if (source.recursiveDependence) {
const files = glob.sync(['**/*.{js,mjs}'], {
cwd: join(root, 'node_modules', source.name)
})
for (const file of files) {
const moduleFile = join(source.name, file)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
}
}
},
transformIndexHtml(html) {
if (Object.keys(importMapEntries).length === 0) {
console.warn(
'[ImportMap Plugin] No vendor chunks found to create import map.'
)
return html
}
const importMap = {
imports: importMapEntries
}
const importMapTag: HtmlTagDescriptor = {
tag: 'script',
attrs: { type: 'importmap' },
children: JSON.stringify(importMap, null, 2),
injectTo: 'head'
}
return {
html,
tags: [importMapTag]
}
}
}
}

View File

@@ -1,2 +1 @@
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

24
cloud-loader-dropdown.md Normal file
View File

@@ -0,0 +1,24 @@
Fixes loader dropdown placeholder
===============================
Cloud loader dropdowns hydrate via `useAssetWidgetData(nodeType)`, so `dropdownItems` stays empty until the Asset API returns friendly filenames. Meanwhile `modelValue` already holds the saved asset and the watcher at [WidgetSelectDropdown.vue#L215-L227](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue#L215-L227) only tracks `modelValue`. It runs before assets load, fails to find a match, clears `selectedSet`, and the placeholder persists.
```ts
watch(
modelValue,
(currentValue) => {
if (currentValue === undefined) {
selectedSet.value.clear()
return
}
const item = dropdownItems.value.find((item) => item.name === currentValue)
if (item) {
selectedSet.value.clear()
selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
```
Once the API resolves, `dropdownItems` recomputes but nothing resyncs because the watcher never sees that change. Desktop doesnt hit this because it still reads from `widget.options.values` immediately.

View File

@@ -191,6 +191,19 @@ export default defineConfig([
'@intlify/vue-i18n/no-raw-text': [
'error',
{
attributes: {
'/.+/': [
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
'label',
'placeholder',
'title',
'v-tooltip'
],
img: ['alt']
},
// Ignore strings that are:
// 1. Less than 2 characters
// 2. Only symbols/numbers/whitespace (no letters)
@@ -200,24 +213,27 @@ export default defineConfig([
ignoreNodes: ['md-icon', 'v-icon', 'pre', 'code', 'script', 'style'],
// Brand names and technical terms that shouldn't be translated
ignoreText: [
'ComfyUI',
'GitHub',
'OpenAI',
'API',
'URL',
'JSON',
'YAML',
'GPU',
'CPU',
'RAM',
'GB',
'MB',
'KB',
'ms',
'fps',
'px',
'App Data:',
'App Path:'
'App Path:',
'ComfyUI',
'CPU',
'fps',
'GB',
'GitHub',
'GPU',
'JSON',
'KB',
'LoRA',
'MB',
'ms',
'OpenAI',
'png',
'px',
'RAM',
'URL',
'YAML',
'1.2 MB'
]
}
]

View File

@@ -0,0 +1,56 @@
<template>
<!-- If user has active subscription, show ComfyQueueButton (which already has FeatureFlaggedRunButton) -->
<ComfyQueueButton v-if="isActiveSubscription" />
<!-- If subscription required but not active, wrap SubscribeToRunButton with feature flag -->
<FeatureFlaggedRunButton
v-else
flag-key="demo-run-button-experiment"
:on-click="handleSubscribeClick"
>
<template #control>
<!-- SubscribeToRunButton handles its own click (shows subscription dialog) -->
<SubscribeToRunButton />
</template>
</FeatureFlaggedRunButton>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import ComfyQueueButton from './ComfyQueueButton.vue'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.vue'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
// Get subscription status - replace with actual subscription store/composable
const isActiveSubscription = computed(() => {
// TODO: Replace with actual subscription check
// Example: return useSubscriptionStore().isActiveSubscription
// For now, this would typically come from a store or composable
return false
})
const commandStore = useCommandStore()
// Handle click for experimental button when SubscribeToRunButton is shown
// For experimental variants, trigger queue prompt (same as ComfyQueueButton)
// For control, SubscribeToRunButton handles its own click (shows subscription dialog)
const handleSubscribeClick = async (e: Event) => {
// If this is called from the experimental button, trigger queue prompt
// If called from SubscribeToRunButton (control), it will handle its own logic
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
}
</script>

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.8",
"version": "1.33.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -164,7 +164,6 @@
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"fast-glob": "^3.3.3",
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",

View File

@@ -1329,57 +1329,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

@@ -75,6 +75,17 @@ export function formatSize(value?: number) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
/**
* Formats a commit hash by truncating long (40-char) hashes to 7 chars.
* Returns the original string if not a valid full commit hash.
*/
export function formatCommitHash(value: string): string {
if (/^[a-f0-9]{40}$/i.test(value)) {
return value.slice(0, 7)
}
return value
}
/**
* Returns various filename components.
* Example:

17
pnpm-lock.yaml generated
View File

@@ -15,15 +15,9 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -431,9 +425,6 @@ importers:
extendable-media-recorder-wav-encoder:
specifier: ^7.0.129
version: 7.0.129
fast-glob:
specifier: ^3.3.3
version: 3.3.3
firebase:
specifier: 'catalog:'
version: 11.6.0
@@ -7877,8 +7868,8 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-component-type-helpers@3.1.5:
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -10681,7 +10672,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.4
vue-component-type-helpers: 3.1.5
'@swc/helpers@0.5.17':
dependencies:
@@ -16448,7 +16439,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.4: {}
vue-component-type-helpers@3.1.5: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -1 +1,9 @@
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.
Node usage data merged from two sources:
- Mixpanel "app:node_search_result_selected" events (Nov 2025): 1,112 nodes, 46,514 selections
Reflects actual user search behavior - what users choose when searching.
- OpenArt workflow data (Sept 2024): 2,600 nodes, 118,676 uses
Reflects overall popularity - what's used in workflows.
Merge strategy: New data overwrites old for 514 overlapping nodes. Old data
normalized by 2.55x scale factor to match new data. Total: 3,198 nodes.
Search-selected nodes prioritized in ranking.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
@@ -40,7 +45,10 @@
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
@@ -69,6 +77,7 @@ const isDesktop = isElectron()
const { t } = useI18n()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))

View File

@@ -257,7 +257,7 @@ watch(isDragging, (dragging) => {
})
const actionbarClass = computed(() =>
cn(
'w-[265px] border-dashed border-blue-500 opacity-80',
'w-[200px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',

View File

@@ -1,35 +1,42 @@
<template>
<div class="queue-button-group flex">
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
<FeatureFlaggedRunButton
flag-key="demo-run-button-experiment"
:on-click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
<template #control>
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
class="comfyui-queue-button"
:label="String(activeQueueModeMenuItem?.label ?? '')"
severity="primary"
size="small"
text
/>
:model="queueModeMenuItems"
data-testid="queue-button"
@click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:label="String(item.label ?? '')"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"
text
/>
</template>
</SplitButton>
</template>
</SplitButton>
</FeatureFlaggedRunButton>
<BatchCountEdit />
</div>
</template>
@@ -44,17 +51,23 @@ 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'
import FeatureFlaggedRunButton from './FeatureFlaggedRunButton.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

@@ -0,0 +1,252 @@
<template>
<div
:class="buttonClasses"
:style="buttonStyles"
class="experimental-run-button relative overflow-hidden transition-all duration-300"
@click="handleClick"
>
<!-- Animated background for gradient variant -->
<div
v-if="variantName === 'bold-gradient'"
class="absolute inset-0 animate-gradient bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 opacity-90"
/>
<!-- Pulsing animation for animated variant -->
<div
v-if="variantName === 'animated'"
class="absolute inset-0 animate-pulse bg-primary-background opacity-20"
/>
<!-- Sparkle effect for playful variant -->
<div
v-if="variantName === 'playful'"
class="absolute inset-0 overflow-hidden"
>
<i
v-for="i in 3"
:key="i"
:class="sparkleClasses[i - 1]"
class="absolute animate-sparkle text-yellow-300"
:style="sparkleStyles[i - 1]"
>
</i>
</div>
<!-- Button content -->
<div class="relative z-10 flex items-center justify-center gap-2">
<i :class="iconClass" class="text-lg" />
<span :class="labelClasses" :style="labelStyles" class="font-semibold">
{{ label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
const props = defineProps<{
variant: FeatureFlagVariant
onClick: () => void
}>()
const variantName = computed(() => props.variant.variant)
const payload = computed(() => props.variant.payload || {})
// Extract styling from payload with defaults
const backgroundColor = computed(
() => (payload.value.backgroundColor as string) || getDefaultColor()
)
const textColor = computed(
() => (payload.value.textColor as string) || getDefaultTextColor()
)
const borderRadius = computed(
() => (payload.value.borderRadius as string) || getDefaultBorderRadius()
)
const icon = computed(() => (payload.value.icon as string) || getDefaultIcon())
const label = computed(() => (payload.value.label as string) || 'Run')
const padding = computed(() => (payload.value.padding as string) || 'px-4 py-2')
// Size matching - should match PrimeVue small button size
const buttonSize = computed(() => {
return 'text-sm' // Match PrimeVue small button
})
function getDefaultColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'transparent' // Gradient overlay handles it
case 'animated':
return 'bg-primary-background'
case 'playful':
return 'bg-gradient-to-br from-yellow-400 to-orange-500'
case 'minimal':
return 'bg-white border-2 border-gray-300'
default:
return 'bg-primary-background'
}
}
function getDefaultTextColor(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'text-white'
case 'animated':
return 'text-white'
case 'playful':
return 'text-white'
case 'minimal':
return 'text-gray-800'
default:
return 'text-white'
}
}
function getDefaultBorderRadius(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'rounded-xl'
case 'animated':
return 'rounded-full'
case 'playful':
return 'rounded-2xl'
case 'minimal':
return 'rounded-md'
default:
return 'rounded-lg'
}
}
function getDefaultIcon(): string {
switch (variantName.value) {
case 'bold-gradient':
return 'icon-[lucide--zap]'
case 'animated':
return 'icon-[lucide--rocket]'
case 'playful':
return 'icon-[lucide--sparkles]'
case 'minimal':
return 'icon-[lucide--play]'
default:
return 'icon-[lucide--play]'
}
}
const buttonClasses = computed(() => {
const base = [
'cursor-pointer',
'select-none',
'flex',
'items-center',
'justify-center',
padding.value,
borderRadius.value,
'shadow-lg',
'hover:scale-105',
'active:scale-95',
'transition-transform',
buttonSize.value
]
// Add variant-specific classes
if (variantName.value === 'bold-gradient') {
base.push('text-white', 'font-bold')
} else if (variantName.value === 'animated') {
base.push('text-white', 'font-bold', 'hover:shadow-2xl')
} else if (variantName.value === 'playful') {
base.push('text-white', 'font-bold', 'hover:rotate-1')
} else if (variantName.value === 'minimal') {
base.push('bg-white', 'text-gray-800', 'hover:bg-gray-50')
} else {
base.push(backgroundColor.value, textColor.value)
}
return base.join(' ')
})
const buttonStyles = computed(() => {
const styles: Record<string, string> = {}
// Apply custom styles from payload
if (payload.value.backgroundColor && variantName.value !== 'bold-gradient') {
styles.backgroundColor = backgroundColor.value
}
if (payload.value.textColor) {
styles.color = textColor.value
}
if (payload.value.borderRadius && !borderRadius.value.includes('rounded')) {
styles.borderRadius = borderRadius.value
}
return styles
})
const iconClass = computed(() => icon.value)
// Text color handling - can be a CSS class or a color value
const labelClasses = computed(() => {
// If textColor is a Tailwind class, return it; otherwise it's handled by inline styles
if (textColor.value.startsWith('text-')) {
return textColor.value
}
return ''
})
const labelStyles = computed(() => {
const styles: Record<string, string> = {}
// If textColor is not a Tailwind class, use it as a color value
if (!textColor.value.startsWith('text-')) {
styles.color = textColor.value
}
return styles
})
// Sparkle animation positions for playful variant
const sparkleClasses = ['top-2 left-4', 'top-4 right-6', 'bottom-2 left-1/2']
const sparkleStyles = [
{ animationDelay: '0s', animationDuration: '2s' },
{ animationDelay: '0.5s', animationDuration: '2.5s' },
{ animationDelay: '1s', animationDuration: '3s' }
]
function handleClick() {
props.onClick()
}
</script>
<style scoped>
@keyframes gradient {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 3s ease infinite;
}
@keyframes sparkle {
0%,
100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
.animate-sparkle {
animation: sparkle 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="feature-flagged-run-button">
<!-- Control: Original button -->
<slot v-if="!isExperimentActive" name="control" />
<!-- Experiment: Experimental button -->
<ExperimentalRunButton
v-else-if="variant"
:variant="variant"
:on-click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { FeatureFlagVariant } from '@/composables/useFeatureFlags'
import ExperimentalRunButton from './ExperimentalRunButton.vue'
const props = defineProps<{
flagKey?: string
onClick: (e: Event) => void | Promise<void>
}>()
const flagKey = computed(() => props.flagKey || 'demo-run-button-experiment')
const { featureFlag } = useFeatureFlags()
const flagValue = featureFlag<FeatureFlagVariant | boolean | null>(
flagKey.value,
false
)
const variant = computed<FeatureFlagVariant | null>(() => {
const value = flagValue.value
if (
typeof value === 'object' &&
value !== null &&
'variant' in value &&
typeof (value as FeatureFlagVariant).variant === 'string'
) {
return value as FeatureFlagVariant
}
return null
})
const isExperimentActive = computed(() => {
return variant.value !== null
})
const handleClick = () => {
// Create a synthetic event for the onClick handler
const syntheticEvent = new Event('click') as Event
props.onClick(syntheticEvent)
}
</script>

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

@@ -9,29 +9,31 @@
<div class="font-medium">
{{ col.header }}
</div>
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
<div>{{ getDisplayValue(col) }}</div>
</template>
</div>
</div>
<Divider />
<template v-if="hasDevices">
<Divider />
<div>
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.devices') }}
</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
<div>
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.devices') }}
</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
</div>
</template>
</div>
</template>
@@ -42,8 +44,9 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -54,20 +57,53 @@ const systemInfo = computed(() => ({
argv: props.stats.system.argv.join(' ')
}))
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
[
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const hasDevices = computed(() => props.stats.devices.length > 0)
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {
return formatSize(value)
type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
/** Columns for local distribution */
const localColumns: ColumnDef[] = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
]
/** Columns for cloud distribution */
const cloudColumns: ColumnDef[] = [
{ field: 'cloud_version', header: 'Cloud Version' },
{
field: 'comfyui_version',
header: 'ComfyUI Version',
format: formatCommitHash
},
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
]
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
const getDisplayValue = (column: ColumnDef) => {
const value = systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}
if (column.format && typeof value === 'string') {
return column.format(value)
}
return value
}

View File

@@ -152,7 +152,7 @@ const {
popoverMaxWidth?: string
}>()
const selectedItem = defineModel<string | null>({ required: true })
const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()

View File

@@ -80,7 +80,7 @@
/>
<SliderControl
label="Stepsize"
:label="$t('maskEditor.stepSize')"
:min="1"
:max="100"
:step="1"

View File

@@ -25,7 +25,11 @@
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
>
<img :src="url" alt="preview" class="h-full w-full object-cover" />
<img
:src="url"
:alt="$t('sideToolbar.queueProgressOverlay.preview')"
class="h-full w-full object-cover"
/>
</span>
</span>

View File

@@ -47,7 +47,7 @@
v-tooltip.top="cancelJobTooltip"
type="secondary"
size="sm"
class="size-6 bg-secondary-background hover:bg-destructive-background"
class="size-6 bg-destructive-background hover:bg-destructive-background-hover"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>

View File

@@ -60,7 +60,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, ref, withDefaults } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
@@ -85,9 +85,15 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
const props = defineProps<{
expanded?: boolean
}>()
const props = withDefaults(
defineProps<{
expanded?: boolean
menuHovered?: boolean
}>(),
{
menuHovered: false
}
)
const emit = defineEmits<{
(e: 'update:expanded', value: boolean): void
@@ -110,6 +116,7 @@ const {
currentNodeProgressStyle
} = useQueueProgress()
const isHovered = ref(false)
const isOverlayHovered = computed(() => isHovered.value || props.menuHovered)
const internalExpanded = ref(false)
const isExpanded = computed({
get: () =>
@@ -142,7 +149,7 @@ const showBackground = computed(
() =>
overlayState.value === 'expanded' ||
overlayState.value === 'empty' ||
(overlayState.value === 'active' && isHovered.value)
(overlayState.value === 'active' && isOverlayHovered.value)
)
const isVisible = computed(() => overlayState.value !== 'hidden')
@@ -156,7 +163,7 @@ const containerClass = computed(() =>
const bottomRowClass = computed(
() =>
`flex items-center justify-end gap-4 transition-opacity duration-200 ease-in-out ${
overlayState.value === 'active' && isHovered.value
overlayState.value === 'active' && isOverlayHovered.value
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
}`

View File

@@ -82,7 +82,10 @@
:src="iconImageUrl"
class="h-full w-full object-cover"
/>
<i v-else :class="[iconClass, 'size-4']" />
<i
v-else
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
/>
</div>
</div>
</div>
@@ -93,6 +96,23 @@
</div>
</div>
<!--
TODO: Refactor action buttons to use a declarative config system.
Instead of hardcoding button visibility logic in the template, define an array of
action button configs with properties like:
- icon, label, action, tooltip
- visibleStates: JobState[] (which job states show this button)
- alwaysVisible: boolean (show without hover)
- destructive: boolean (use destructive styling)
Then render buttons in two groups:
1. Always-visible buttons (outside Transition)
2. Hover-only buttons (inside Transition)
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
<div class="relative z-[1] flex items-center gap-2 text-text-secondary">
<Transition
mode="out-in"
@@ -113,18 +133,22 @@
v-tooltip.top="deleteTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
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')"
>
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton
v-else-if="props.state !== 'completed' && computedShowClear"
v-else-if="
props.state !== 'completed' &&
props.state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:bg-destructive-background hover:opacity-95"
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')"
>
@@ -143,17 +167,33 @@
v-tooltip.top="moreTooltipConfig"
type="transparent"
size="sm"
class="h-6 transform gap-1 rounded bg-modal-card-button-surface px-1 py-0 text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
class="size-6 transform gap-1 rounded bg-modal-card-button-surface text-text-primary transition duration-150 ease-in-out hover:-translate-y-px hover:opacity-95"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</IconButton>
</div>
<div v-else key="secondary" class="pr-2">
<div
v-else-if="props.state !== 'running'"
key="secondary"
class="pr-2"
>
<slot name="secondary">{{ props.rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<IconButton
v-if="props.state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
type="transparent"
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')"
>
<i class="icon-[lucide--x] size-4" />
</IconButton>
</div>
</div>
</div>
@@ -170,6 +210,7 @@ import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<{
@@ -302,6 +343,13 @@ const iconClass = computed(() => {
return iconForJobState(props.state)
})
const shouldSpin = computed(
() =>
props.state === 'pending' &&
iconClass.value === iconForJobState('pending') &&
!props.iconImageUrl
)
const computedShowClear = computed(() => {
if (props.showClear !== undefined) return props.showClear
return props.state !== 'completed'

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,9 +3,12 @@
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">
<div class="flex items-center text-sm">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
$t('vueNodesBanner.desc')
}}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
@@ -63,7 +66,7 @@ const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Vue nodes:', error)
console.error('Failed to enable Nodes 2.0:', error)
} finally {
handleDismiss()
}

View File

@@ -30,7 +30,7 @@
<template #default="{ close }">
<IconTextButton
type="secondary"
label="Settings"
:label="$t('g.settings')"
@click="
() => {
close()
@@ -43,7 +43,7 @@
</IconTextButton>
<IconTextButton
type="primary"
label="Profile"
:label="$t('g.profile')"
@click="
() => {
close()
@@ -65,7 +65,7 @@
v-model="selectedFrameworks"
v-model:search-query="searchText"
class="w-[250px]"
label="Select Frameworks"
:label="$t('assetBrowser.selectFrameworks')"
:options="frameworkOptions"
:show-search-box="true"
:show-selected-count="true"
@@ -73,12 +73,12 @@
/>
<MultiSelect
v-model="selectedProjects"
label="Select Projects"
:label="$t('assetBrowser.selectProjects')"
:options="projectOptions"
/>
<SingleSelect
v-model="selectedSort"
label="Sorting Type"
:label="$t('assetBrowser.sortingType')"
:options="sortOptions"
class="w-[135px]"
>

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'
@@ -303,6 +325,46 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
Flux2ProImageNode: {
displayPrice: (node: LGraphNode): string => {
const widthW = node.widgets?.find(
(w) => w.name === 'width'
) as IComboWidget
const heightW = node.widgets?.find(
(w) => w.name === 'height'
) as IComboWidget
const w = Number(widthW?.value)
const h = Number(heightW?.value)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
// global min/max for this node given schema bounds (1MP..4MP output)
return '$0.03$0.15/Run'
}
// Is the 'images' input connected?
const imagesInput = node.inputs?.find(
(i) => i.name === 'images'
) as INodeInputSlot
const hasRefs =
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
// Output cost: ceil((w*h)/MP); first MP $0.03, each additional $0.015
const MP = 1024 * 1024
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
const outputCost = 0.03 + 0.015 * Math.max(outMP - 1, 0)
if (hasRefs) {
// Unknown ref count/size on the frontend:
// min extra is $0.015, max extra is $0.120 (8 MP cap / 8 refs)
const minTotal = outputCost + 0.015
const maxTotal = outputCost + 0.12
return `~$${parseFloat(minTotal.toFixed(3))}$${parseFloat(maxTotal.toFixed(3))}/Run`
}
// Precise text-to-image price
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
@@ -659,6 +721,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
@@ -1197,6 +1274,40 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.80-3.20/Run'
}
},
Veo3FirstLastFrameNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!modelWidget || !generateAudioWidget || !durationWidget) {
return '$0.40-3.20/Run (varies with model & audio generation)'
}
const model = String(modelWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
const seconds = parseFloat(String(durationWidget.value))
let pricePerSecond: number | null = null
if (model.includes('veo-3.1-fast-generate')) {
pricePerSecond = generateAudio ? 0.15 : 0.1
} else if (model.includes('veo-3.1-generate')) {
pricePerSecond = generateAudio ? 0.4 : 0.2
}
if (pricePerSecond === null) {
return '$0.40-3.20/Run'
}
const cost = pricePerSecond * seconds
return `$${cost.toFixed(2)}/Run`
}
},
LumaImageNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
@@ -1799,6 +1910,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'],
@@ -1809,8 +1924,10 @@ export const useNodePricing = () => {
IdeogramV3: ['rendering_speed', 'num_images', 'character_image'],
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
Flux2ProImageNode: ['width', 'height', 'images'],
VeoVideoGenerationNode: ['duration_seconds'],
Veo3VideoGenerationNode: ['model', 'generate_audio'],
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
LumaVideoNode: ['model', 'resolution', 'duration'],
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
LumaImageNode: ['model', 'aspect_ratio'],

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -96,6 +97,7 @@ export function useJobList() {
const executionStore = useExecutionStore()
const workflowStore = useWorkflowStore()
const seenPendingIds = ref<Set<string>>(new Set())
const recentlyAddedPendingIds = ref<Set<string>>(new Set())
const addedHintTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
@@ -126,23 +128,27 @@ export function useJobList() {
.filter((id): id is string => !!id),
(pendingIds) => {
const pendingSet = new Set(pendingIds)
const next = new Set(recentlyAddedPendingIds.value)
const nextAdded = new Set(recentlyAddedPendingIds.value)
const nextSeen = new Set(seenPendingIds.value)
pendingIds.forEach((id) => {
if (!next.has(id)) {
next.add(id)
if (!nextSeen.has(id)) {
nextSeen.add(id)
nextAdded.add(id)
scheduleAddedHintExpiry(id)
}
})
for (const id of Array.from(next)) {
for (const id of Array.from(nextSeen)) {
if (!pendingSet.has(id)) {
next.delete(id)
nextSeen.delete(id)
nextAdded.delete(id)
clearAddedHintTimeout(id)
}
}
recentlyAddedPendingIds.value = next
recentlyAddedPendingIds.value = nextAdded
seenPendingIds.value = nextSeen
},
{ immediate: true }
)
@@ -157,6 +163,7 @@ export function useJobList() {
onUnmounted(() => {
addedHintTimeouts.forEach((timeoutId) => clearTimeout(timeoutId))
addedHintTimeouts.clear()
seenPendingIds.value = new Set<string>()
recentlyAddedPendingIds.value = new Set<string>()
})
@@ -257,7 +264,8 @@ export function useJobList() {
totalPercent: isActive ? totalPercent.value : undefined,
currentNodePercent: isActive ? currentNodePercent.value : undefined,
currentNodeName: isActive ? currentNodeName.value : undefined,
showAddedHint
showAddedHint,
isCloud
})
return {

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

@@ -330,7 +330,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: () =>
`Experimental: ${
useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
} Vue Nodes`,
} Nodes 2.0`,
function: async () => {
const settingStore = useSettingStore()
const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false

View File

@@ -1,5 +1,6 @@
import { computed, reactive, readonly } from 'vue'
import { computed, reactive, readonly, ref } from 'vue'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
/**
@@ -9,9 +10,21 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
}
/**
* Feature flag variant structure for experiments
*/
export interface FeatureFlagVariant {
variant: string
payload?: Record<string, unknown>
}
// Demo mode: allows manual override for demonstration
const demoOverrides = ref<Record<string, unknown>>({})
/**
* Composable for reactive access to server-side feature flags
*/
@@ -27,15 +40,57 @@ export function useFeatureFlags() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
return api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.model_upload_button_enabled ??
api.getServerFeature(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
)
},
get assetUpdateOptionsEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.asset_update_options_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
)
}
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
computed(() => {
// Check demo overrides first
if (demoOverrides.value[featurePath] !== undefined) {
return demoOverrides.value[featurePath] as T
}
// Check remote config (from /api/features) - convert hyphens to underscores for lookup
const remoteConfigKey = featurePath.replace(/-/g, '_')
const remoteValue = (remoteConfig.value as Record<string, unknown>)[
remoteConfigKey
]
if (remoteValue !== undefined) {
return remoteValue as T
}
// Fall back to server feature flags (WebSocket) - try both hyphen and underscore versions
const wsValue = api.getServerFeature(featurePath, undefined)
if (wsValue !== undefined) {
return wsValue as T
}
// Try underscore version for WebSocket flags
const wsValueUnderscore = api.getServerFeature(
featurePath.replace(/-/g, '_'),
undefined
)
if (wsValueUnderscore !== undefined) {
return wsValueUnderscore as T
}
// Return default if nothing found
return defaultValue as T
})
return {
flags: readonly(flags),

View File

@@ -9,7 +9,7 @@ useExtensionService().registerExtension({
name: 'Comfy.Cloud.RemoteConfig',
setup: async () => {
// Poll for config updates every 30 seconds
setInterval(() => void loadRemoteConfig(), 30000)
// Poll for config updates every 10 minutes
setInterval(() => void loadRemoteConfig(), 600_000)
}
})

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)

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)

View File

@@ -32,6 +32,7 @@ export interface IWidgetOptions<TValues = unknown[]> {
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
iconClass?: string
}
interface IWidgetSliderOptions extends IWidgetOptions<number[]> {

View File

@@ -237,7 +237,7 @@
"label": "Sign Out"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Enable Vue Nodes"
"label": "Experimental: Enable Nodes 2.0"
},
"Workspace_CloseWorkflow": {
"label": "Close Current Workflow"

View File

@@ -1,5 +1,6 @@
{
"g": {
"beta": "Beta",
"user": "User",
"currentUser": "Current user",
"empty": "Empty",
@@ -105,6 +106,7 @@
"dropYourFileOr": "Drop your file or",
"back": "Back",
"next": "Next",
"submit": "Submit",
"install": "Install",
"installing": "Installing",
"overwrite": "Overwrite",
@@ -234,6 +236,7 @@
"frameNodes": "Frame Nodes",
"listening": "Listening...",
"ready": "Ready",
"playPause": "Play/Pause",
"playRecording": "Play Recording",
"playing": "Playing",
"stopPlayback": "Stop Playback",
@@ -242,7 +245,9 @@
"halfSpeed": "0.5x",
"1x": "1x",
"2x": "2x",
"beta": "BETA"
"beta": "BETA",
"profile": "Profile",
"noItems": "No items"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -689,6 +694,7 @@
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"running": "running",
"preview": "Preview",
"interruptAll": "Interrupt all running jobs",
"moreOptions": "More options",
"showAssets": "Show assets",
@@ -930,6 +936,7 @@
"thickness": "Thickness",
"opacity": "Opacity",
"hardness": "Hardness",
"stepSize": "Step Size",
"smoothingPrecision": "Smoothing Precision",
"resetToDefault": "Reset to Default",
"paintBucketSettings": "Paint Bucket Settings",
@@ -978,6 +985,7 @@
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
"openWorkflowNewTab": "Open workflow in new tab",
@@ -1107,7 +1115,9 @@
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
"Sign Out": "Sign Out",
"Experimental: Enable Vue Nodes": "Experimental: Enable Vue Nodes",
"Experimental: Enable Vue Nodes": "Experimental: Enable Nodes 2.0",
"Experimental: Enable Nodes 2.0": "Experimental: Enable Nodes 2.0",
"Experimental: Disable Nodes 2.0": "Experimental: Disable Nodes 2.0",
"Close Current Workflow": "Close Current Workflow",
"Next Opened Workflow": "Next Opened Workflow",
"Previous Opened Workflow": "Previous Opened Workflow",
@@ -1183,10 +1193,10 @@
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"VueNodes": "Vue Nodes",
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0"
},
"serverConfigItems": {
@@ -1833,6 +1843,7 @@
"title": "Subscription",
"titleUnsubscribed": "Subscribe to Comfy Cloud",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud Logo",
"beta": "BETA",
"perMonth": "USD / month",
"renewsDate": "Renews {date}",
@@ -2068,6 +2079,7 @@
"cloudSurvey_steps_making": "What do you plan on making?",
"assetBrowser": {
"assets": "Assets",
"assetCollection": "Asset collection",
"checkpoints": "Checkpoints",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
@@ -2082,11 +2094,11 @@
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
"uploadModelDescription3": "Max file size: 1 GB",
"civitaiLinkLabel": "Civitai model download link",
"uploadModelDescription2": "Only links from <a href=\"https://civitai.com\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com</a> are supported at the moment",
"uploadModelDescription3": "Max file size: <strong>1 GB</strong>",
"civitaiLinkLabel": "Civitai model <span class=\"font-bold italic\">download</span> link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
"civitaiLinkExample": "<strong>Example:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
@@ -2118,6 +2130,9 @@
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"selectFrameworks": "Select Frameworks",
"selectProjects": "Select Projects",
"sortingType": "Sorting Type",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
@@ -2189,7 +2204,8 @@
}
},
"vueNodesBanner": {
"message": "Introducing Nodes 2.0 More flexible workflows, powerful new widgets, built for extensibility",
"title": "Introducing Nodes 2.0",
"desc": " More flexible workflows, powerful new widgets, built for extensibility",
"tryItOut": "Try it out"
},
"vueNodesMigration": {
@@ -2218,4 +2234,4 @@
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
}
}
}

View File

@@ -336,7 +336,7 @@
},
"Comfy_VueNodes_AutoScaleLayout": {
"name": "Auto-scale layout (Nodes 2.0)",
"tooltip": "Automatically scale node positions when switching to Vue rendering to prevent overlap"
"tooltip": "Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap"
},
"Comfy_VueNodes_Enabled": {
"name": "Modern Node Design (Nodes 2.0)",

View File

@@ -201,6 +201,12 @@ function handleUploadClick() {
onUploadSuccess: async () => {
await execute()
}
},
dialogComponentProps: {
pt: {
header: 'py-0! pl-0!',
content: 'p-0!'
}
}
})
}

View File

@@ -3,7 +3,7 @@
data-component-id="AssetGrid"
:style="gridStyle"
role="grid"
aria-label="Asset collection"
:aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1"
:aria-colcount="-1"
:aria-setsize="assets.length"

View File

@@ -6,7 +6,7 @@
<IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent"
label="Inspect asset"
:label="$t('queue.jobMenu.inspectAsset')"
@click="handleInspect"
>
<template #icon>
@@ -17,7 +17,7 @@
<IconTextButton
v-if="showAddToWorkflow"
type="transparent"
label="Add to current workflow"
:label="$t('queue.jobMenu.addToCurrentWorkflow')"
@click="handleAddToWorkflow"
>
<template #icon>
@@ -25,7 +25,11 @@
</template>
</IconTextButton>
<IconTextButton type="transparent" label="Download" @click="handleDownload">
<IconTextButton
type="transparent"
:label="$t('queue.jobMenu.download')"
@click="handleDownload"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
@@ -36,7 +40,7 @@
<IconTextButton
v-if="showWorkflowActions"
type="transparent"
label="Open as workflow in new tab"
:label="$t('queue.jobMenu.openAsWorkflowNewTab')"
@click="handleOpenWorkflow"
>
<template #icon>
@@ -47,7 +51,7 @@
<IconTextButton
v-if="showWorkflowActions"
type="transparent"
label="Export workflow"
:label="$t('queue.jobMenu.exportWorkflow')"
@click="handleExportWorkflow"
>
<template #icon>
@@ -60,7 +64,7 @@
<IconTextButton
v-if="showCopyJobId"
type="transparent"
label="Copy job ID"
:label="$t('queue.jobMenu.copyJobId')"
@click="handleCopyJobId"
>
<template #icon>
@@ -73,7 +77,7 @@
<IconTextButton
v-if="shouldShowDeleteButton"
type="transparent"
label="Delete"
:label="$t('queue.jobMenu.delete')"
@click="handleDelete"
>
<template #icon>

View File

@@ -1,22 +1,24 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<!-- Model Info Section -->
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
</p>
<p class="text-sm mt-0">
<p
class="mt-0 bg-modal-card-background text-base-foreground p-3 rounded-lg"
>
{{ metadata?.name || metadata?.filename }}
</p>
</div>
<!-- Model Type Selection -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted">
<label class="">
{{ $t('assetBrowser.modelTypeSelectorLabel') }}
</label>
<SingleSelect
v-model="selectedModelType"
v-model="modelValue"
:label="
isLoading
? $t('g.loading')
@@ -25,8 +27,8 @@
:options="modelTypes"
:disabled="isLoading"
/>
<div class="flex items-center gap-2 text-sm text-muted">
<i class="icon-[lucide--info]" />
<div class="flex items-center gap-2">
<i class="icon-[lucide--circle-question-mark]" />
<span>{{ $t('assetBrowser.notSureLeaveAsIs') }}</span>
</div>
</div>
@@ -34,25 +36,15 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
modelValue: string | undefined
defineProps<{
metadata: AssetMetadata | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | undefined]
}>()
const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const selectedModelType = computed({
get: () => props.modelValue ?? null,
set: (value: string | null) => emit('update:modelValue', value ?? undefined)
})
</script>

View File

@@ -1,5 +1,7 @@
<template>
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
<div
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"

View File

@@ -1,12 +1,11 @@
<template>
<div class="flex items-center gap-3 px-4 py-2 font-bold">
<div class="flex items-center gap-2 p-4 font-bold">
<img src="/assets/images/civitai.svg" class="size-4" />
<span>{{ $t('assetBrowser.uploadModelFromCivitai') }}</span>
<span
class="rounded-full bg-white px-1.5 py-0 text-xxs font-medium uppercase text-black"
class="rounded-full bg-white px-1.5 py-0 text-xxs font-inter font-semibold uppercase text-black"
>
{{ $t('g.beta') }}
</span>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -1,9 +1,26 @@
<template>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2 w-full">
<span
v-if="currentStep === 1"
class="text-muted-foreground mr-auto underline flex items-center gap-2"
>
<i class="icon-[lucide--circle-question-mark]" />
<a href="#" target="_blank" class="text-muted-foreground">{{
$t('How do I find this?')
}}</a>
</span>
<TextButton
v-if="currentStep === 1"
:label="$t('g.cancel')"
type="transparent"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('close')"
/>
<TextButton
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="secondary"
type="transparent"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
@@ -13,7 +30,7 @@
<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
type="secondary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
@click="emit('fetchMetadata')"
@@ -28,7 +45,7 @@
<IconTextButton
v-else-if="currentStep === 2"
:label="$t('assetBrowser.upload')"
type="primary"
type="secondary"
size="md"
:disabled="!canUploadModel || isUploading"
@click="emit('upload')"
@@ -43,7 +60,7 @@
<TextButton
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="primary"
type="secondary"
size="md"
@click="emit('close')"
/>

View File

@@ -1,37 +1,38 @@
<template>
<div class="flex flex-1 flex-col gap-6">
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-6"
class="flex flex-1 flex-col items-center justify-center gap-2"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
/>
<div class="text-center">
<p class="m-0 text-sm font-bold">
<p class="m-0 font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
</div>
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
<div
class="flex flex-row items-start p-4 bg-modal-card-background rounded-lg"
>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-sm m-0">
<p class="text-base-foreground m-0">
{{ metadata?.name || metadata?.filename }}
</p>
<p class="text-sm text-muted m-0">
<!-- Going to want to add another translation here to get a nice display name. -->
{{ modelType }}
</p>
</div>

View File

@@ -1,30 +1,27 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-6 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
<p class="m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
<ul class="list-disc space-y-1 pl-5 mt-0">
<li v-html="$t('assetBrowser.uploadModelDescription2')" />
<li v-html="$t('assetBrowser.uploadModelDescription3')" />
</ul>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<label class="mb-0" v-html="$t('assetBrowser.civitaiLinkLabel')"> </label>
<InputText
v-model="url"
autofocus
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
class="w-full"
class="w-full bg-secondary-background border-0 p-4"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-xs text-muted">
{{ $t('assetBrowser.civitaiLinkExample') }}
</p>
<p v-else v-html="$t('assetBrowser.civitaiLinkExample')"></p>
</div>
</div>
</template>

View File

@@ -4,18 +4,18 @@ import { api } from '@/scripts/api'
/**
* Format folder name to display name
* Converts "upscale_models" -> "Upscale Models"
* Converts "loras" -> "LoRAs"
* Converts "upscale_models" -> "Upscale Model"
* Converts "loras" -> "LoRA"
*/
function formatDisplayName(folderName: string): string {
// Special cases for acronyms and proper nouns
const specialCases: Record<string, string> = {
loras: 'LoRAs',
loras: 'LoRA',
ipadapter: 'IP-Adapter',
sams: 'SAMs',
sams: 'SAM',
clip_vision: 'CLIP Vision',
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
animatediff_models: 'AnimateDiff Models',
animatediff_models: 'AnimateDiff Model',
vae: 'VAE',
sam2: 'SAM 2',
controlnet: 'ControlNet',

View File

@@ -31,7 +31,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
tags: []
})
const selectedModelType = ref<string | undefined>(undefined)
const selectedModelType = ref<string>()
// Clear error when URL changes
watch(

View File

@@ -44,7 +44,7 @@
<div class="flex justify-between pt-4">
<span />
<Button
label="Next"
:label="$t('g.next')"
:disabled="!validStep1"
class="h-10 w-full border-none text-white"
@click="goTo(2, activateCallback)"
@@ -84,20 +84,22 @@
<InputText
v-model="surveyData.useCaseOther"
class="w-full"
placeholder="Please specify"
:placeholder="
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
:label="$t('g.back')"
severity="secondary"
class="flex-1 text-white"
@click="goTo(1, activateCallback)"
/>
<Button
label="Next"
:label="$t('g.next')"
:disabled="!validStep2"
class="h-10 flex-1 text-white"
@click="goTo(3, activateCallback)"
@@ -137,20 +139,22 @@
<InputText
v-model="surveyData.industryOther"
class="w-full"
placeholder="Please specify"
:placeholder="
$t('cloudOnboarding.survey.options.industry.otherPlaceholder')
"
/>
</div>
</div>
<div class="flex gap-6 pt-4">
<Button
label="Back"
:label="$t('g.back')"
severity="secondary"
class="flex-1 text-white"
@click="goTo(2, activateCallback)"
/>
<Button
label="Next"
:label="$t('g.next')"
:disabled="!validStep3"
class="h-10 flex-1 border-none text-white"
@click="goTo(4, activateCallback)"
@@ -189,13 +193,13 @@
<div class="flex gap-6 pt-4">
<Button
label="Back"
:label="$t('g.back')"
severity="secondary"
class="flex-1 text-white"
@click="goTo(3, activateCallback)"
/>
<Button
label="Submit"
:label="$t('g.submit')"
:disabled="!validStep4 || isSubmitting"
:loading="isSubmitting"
class="h-10 flex-1 border-none text-white"

View File

@@ -2,7 +2,7 @@
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
<img
src="/assets/images/comfy-cloud-logo.svg"
alt="Comfy Cloud Logo"
:alt="$t('subscription.comfyCloudLogo')"
class="h-3/4 max-h-10 w-auto"
/>
</div>

View File

@@ -1,3 +1,5 @@
import type { TelemetryEventName } from '@/platform/telemetry/types'
/**
* Server health alert configuration from the backend
*/
@@ -31,4 +33,7 @@ export type RemoteConfig = {
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
model_upload_button_enabled?: boolean
asset_update_options_enabled?: boolean
}

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',
@@ -1101,7 +1102,7 @@ export const CORE_SETTINGS: SettingParams[] = [
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],
name: 'Auto-scale layout (Nodes 2.0)',
tooltip:
'Automatically scale node positions when switching to Vue rendering to prevent overlap',
'Automatically scale node positions when switching to Nodes 2.0 rendering to prevent overlap',
type: 'boolean',
sortOrder: 50,
experimental: true,

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

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
@@ -13,9 +14,10 @@ import { useTemplateWorkflows } from './useTemplateWorkflows'
* Supports URLs like:
* - /?template=flux_simple (loads with default source)
* - /?template=flux_simple&source=custom (loads from custom source)
* - /?template=flux_simple&mode=linear (loads template in linear mode)
*
* Input validation:
* - Template and source parameters must match: ^[a-zA-Z0-9_-]+$
* - Template, source, and mode parameters must match: ^[a-zA-Z0-9_-]+$
* - Invalid formats are rejected with console warnings
*/
export function useTemplateUrlLoader() {
@@ -24,7 +26,10 @@ export function useTemplateUrlLoader() {
const { t } = useI18n()
const toast = useToast()
const templateWorkflows = useTemplateWorkflows()
const canvasStore = useCanvasStore()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SUPPORTED_MODES = ['linear'] as const
type SupportedMode = (typeof SUPPORTED_MODES)[number]
/**
* Validates parameter format to prevent path traversal and injection attacks
@@ -34,12 +39,20 @@ export function useTemplateUrlLoader() {
}
/**
* Removes template and source parameters from URL
* Type guard to check if a value is a supported mode
*/
const isSupportedMode = (mode: string): mode is SupportedMode => {
return SUPPORTED_MODES.includes(mode as SupportedMode)
}
/**
* Removes template, source, and mode parameters from URL
*/
const cleanupUrlParams = () => {
const newQuery = { ...route.query }
delete newQuery.template
delete newQuery.source
delete newQuery.mode
void router.replace({ query: newQuery })
}
@@ -70,6 +83,24 @@ export function useTemplateUrlLoader() {
return
}
const modeParam = route.query.mode as string | undefined
if (
modeParam &&
(typeof modeParam !== 'string' || !isValidParameter(modeParam))
) {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
}
if (modeParam && !isSupportedMode(modeParam)) {
console.warn(
`[useTemplateUrlLoader] Unsupported mode parameter: ${modeParam}. Supported modes: ${SUPPORTED_MODES.join(', ')}`
)
}
try {
await templateWorkflows.loadTemplates()
@@ -87,6 +118,9 @@ export function useTemplateUrlLoader() {
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
canvasStore.linearMode = true
}
} catch (error) {
console.error(

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
@@ -1414,8 +1418,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 +1430,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

@@ -83,20 +83,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>
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
interface VideoPreviewProps {
/** Array of video URLs to display */
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components

View File

@@ -93,20 +93,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>
@@ -122,8 +119,6 @@ import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
interface ImagePreviewProps {
/** Array of image URLs to display */
readonly imageUrls: readonly string[]

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 {

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