Compare commits

..

66 Commits

Author SHA1 Message Date
github-actions
bc04a88037 Update locales 2025-12-02 02:33:57 +00:00
snomiao
f93db2b923 chore(i18n-update-core.yaml): remove push trigger for sno-* branches and update condition for job execution to include sno- branches 2025-12-02 02:18:26 +00:00
snomiao
e2a10b0594 chore(i18n-update-core.yaml): update branch filter to include all branches matching sno-* for better flexibility
chore(i18n-update-core.yaml): modify job condition to run on push events for improved automation
2025-12-01 22:44:30 +00:00
snomiao
10d9dfaf91 chore(i18n-update-core.yaml): add push trigger for sno-babel-define branch to facilitate debugging during development 2025-12-01 22:37:21 +00:00
snomiao
308213913f Feat: Add Babel plugin for Vite define replacements in Playwright
Implements a solution to handle Vite define replacements during Playwright's
Babel compilation for i18n collection tests. This resolves ReferenceErrors
caused by unprocessed compile-time constants like __DISTRIBUTION__.

Changes:
- Add babel-plugin-vite-define.cjs to replace Vite define constants
- Add babel-plugin-inject-globals.cjs to inject browser globals setup
- Add setup-i18n-globals.mjs for JSDOM-based browser environment
- Update playwright.i18n.config.ts with Babel plugin configuration
- Install babel-plugin-module-resolver for @ alias support

The implementation follows the approach from PR #5515 but is adapted for
the current codebase structure. The Babel plugins run during Playwright's
test compilation to ensure all Vite define constants are replaced with
their actual values before execution.

Fixes #10981

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 05:20:51 +00: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
Alexander Brown
df373af987 Feat: Restore settings button to the sidebar. (#6892)
## Summary

Opens settings with a single click!
<img width="247" height="252" alt="image"
src="https://github.com/user-attachments/assets/c571d5e0-4973-4570-a542-fff343364ec0"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6892-Feat-Restore-settings-button-to-the-sidebar-2b56d73d36508102a48aec318d468303)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-24 12:23:29 -08:00
Alexander Brown
c06a7279e2 Style: Grid for widgets (#6891)
## Summary

Keeps the controls and widgets a consistent width, but lets the size be
more flexible

## Screenshots

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6891-Style-Grid-for-widgets-2b56d73d365081a29c30d337f3be1af6)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-24 11:46:24 -08:00
Benjamin Lu
ffc17c054b Proposal: Run Storybook CI on all pull requests (#6880)
## Summary
Allow the Storybook workflow to run on every PR branch instead of only
`main`.

## Changes
- **What**: remove the `branches: [main]` filter from the Storybook CI
workflow so it triggers for all pull_request events.

## Review Focus
- Confirm broader triggering is desired for all branches while Chromatic
deployment remains restricted to `version-bump-*` or manual runs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6880-Run-Storybook-CI-on-all-pull-requests-2b56d73d36508169a32afcf50f8e8fd8)
by [Unito](https://www.unito.io)

Co-authored-by: sno <snomiao@gmail.com>
2025-11-23 23:29:47 -08:00
Comfy Org PR Bot
ddb00d02d5 1.33.8 (#6885)
Patch version increment to 1.33.8

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-23 22:33:34 -08:00
Benjamin Lu
4815d6b14c Add test id for startup status text (#6882)
## Summary
- add `data-testid="startup-status-text"` to the status text in
StartupDisplay
- keep the status element targetable for Playwright masks

## Why
Desktop’s Playwright tests mask the troubleshooting version line; this
test id is needed there to keep screenshots stable across releases.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6882-Add-test-id-for-startup-status-text-2b56d73d365081b6a2e4ddca9aa985a4)
by [Unito](https://www.unito.io)
2025-11-23 22:54:14 -07:00
Benjamin Lu
a9653ba9c7 Remove unsupported workflow description fields (#6881)
## Summary
Remove top-level `description` keys from workflows because they are not
valid in GitHub Actions workflow syntax (see
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax?utm_source=chatgpt.com).

## Changes
- **What**: delete the unsupported `description:` field from all
workflow YAMLs under `.github/workflows/`.

## Review Focus
- Confirm workflows still show intended names and triggers without the
invalid `description` metadata.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6881-Remove-unsupported-workflow-description-fields-2b56d73d365081ed9f20eb7f57956bc6)
by [Unito](https://www.unito.io)
2025-11-23 22:53:51 -07:00
Christian Byrne
30bafcd019 hide "unload models" and "unload cache" menu entries on cloud (#6879)
Hides these features which the user does not need when on cloud.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6879-hide-unload-models-and-unload-cache-menu-entries-on-cloud-2b46d73d3650816a8e22e913a848e4ac)
by [Unito](https://www.unito.io)
2025-11-23 22:53:18 -07:00
Christian Byrne
b789791fd9 fix: workflow that creates release branch fails (#6878)
Fixes 'create-release-branch' workflow. The script was emitting a
multiline output using `echo "results<<'EOF'" … echo "EOF"`. GitHub
treats the opening delimiter literally (`'EOF'` with quotes). Because
the closing line omits the quotes, the runner never sees a matching
terminator, so it aborts the file-command write and marks the step as
failed even though the preceding git pushes succeeded.

Example of error:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19520246619/job/55881876566

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6878-fix-workflow-that-creates-release-branch-fails-2b46d73d365081549651eacacd5cbfec)
by [Unito](https://www.unito.io)
2025-11-23 22:51:47 -07:00
Christian Byrne
723f53751e fix: duplicate "refresh node definitions" in menu entries (#6876)
The "Refresh node definitions" menu entry was added in the Manager
upstream but while in development was also added in a separate commit,
leading to duplicate menu entries:

<img width="1460" height="324" alt="image"
src="https://github.com/user-attachments/assets/66347cb3-1c52-457e-a4f1-8b32b615a1ca"
/>

This PR removes the second one.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6876-fix-duplicate-refresh-node-definitions-in-menu-entries-2b46d73d365081b98bdfcc60dc9bad36)
by [Unito](https://www.unito.io)
2025-11-23 22:51:36 -07:00
Christian Byrne
2539a7d2ce fix: tabindex prop should be number type in MultiSelect component (#6875)
Change to use number prop to fix warnings:

```
WorkflowTemplateSelectorDialog.vue:7 [Vue warn]: Invalid prop: type check failed for prop "tabindex". Expected Number with value 0, got String with value "0".
  at <MultiSelect modelValue= [] onUpdate:modelValue=fn class="w-[250px]"  ... >
  at <MultiSelect modelValue= [] onUpdate:modelValue=fn search-query=""  ... >
  at <BaseModalLayout content-title="Get Started with a Template" class="workflow-template-selector-dialog" maximized=false >
  at <WorkflowTemplateSelectorDialog ref_for=true onClose=fn<hide> maximized=false >
  at <BaseTransition onEnter=fn onAfterEnter=fn<bound onAfterEnter> onBeforeLeave=fn<bound onBeforeLeave>  ... > 
  at <Transition name="p-dialog" onEnter=fn<bound onEnter> onAfterEnter=fn<bound onAfterEnter>  ... > 
  at <Portal appendTo="body" > 
  at <Dialog key="global-workflow-template-selector" visible=true onUpdate:visible=fn<onUpdate:visible>  ... >
  at <GlobalDialog > 
  at <App>
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6875-fix-tabindex-prop-should-be-number-type-in-MultiSelect-component-2b46d73d3650816d8288fec4cc0f7e7f)
by [Unito](https://www.unito.io)
2025-11-23 22:51:26 -07:00
Christian Byrne
c9556d7aff fix: ordering of Vue mode LOD setting initialization in browser tests (#6884)
Fixes test "should toggle LOD based on zoom threshold" failing (example:
https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19618176782/job/56174006314).

Test execution order:

1. Sets MinFontSizeForLOD = 8
2. Calls setup()
3. App initializes → useVueNodeLifecycle runs → useRenderModeSetting
executes with immediate: true
4. Overrides setting back to 0 (Vue mode value)
5. LOD never activates (threshold = 0)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6884-fix-ordering-of-Vue-mode-LOD-setting-initialization-in-browser-tests-2b56d73d365081209676fb14d2c04d28)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 22:51:07 -07:00
AustinMroz
58b051a473 Share button and Assets Panel in Linear Mode (#6794)
- Re-enables the share button in Linear Mode and have it export the
current workflow
- Not as nice as having it copy an actual URL, but good enough for the
interim and it help with dead space
- Display the Media Assets Panel on the left hand side to replace the
removed Queue Panel

<img width="806" alt="image"
src="https://github.com/user-attachments/assets/93786dfa-8fbb-4368-8594-b9c98bbeb79e"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6794-Share-button-and-Assets-Panel-in-Linear-Mode-2b26d73d36508178aef9ededa38d47f1)
by [Unito](https://www.unito.io)
2025-11-23 12:25:30 -07:00
Alexander Brown
0b33470744 Minor: transformState and setting error cleanup (#6841)
## Summary

Fixes the routing of TransformState through the node and the console
error from a setting that ends up being undefined via
useRenderModeSetting.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6841-Minor-transformState-and-setting-error-cleanup-2b46d73d3650817a8da7fca5bc56ea9a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 12:24:37 -07:00
Comfy Org PR Bot
fb3ce74d2f 1.33.7 (#6856)
Patch version increment to 1.33.7

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 11:47:49 -07:00
Comfy Org PR Bot
86d3f0ebd5 1.33.6 (#6837)
Patch version increment to 1.33.6

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-23 11:31:54 -07:00
Jin Yi
09c888e338 Fix: Selected assets count not updating in Imported tab (#6842)
## Summary
- Fix bug where the "Selected assets count" displayed as 0 in the
Imported tab when selecting assets

## Root Cause
The `getOutputCount` function was returning `0` when
`user_metadata.outputCount` was not present.

- **Generated tab**: Works correctly because `outputCount` metadata is
set during generation
- **Imported tab**: `outputCount` metadata is never set, so it always
returns `0` → selected count shows as 0

## Solution
Changed the default return value from `0` to `1` when `outputCount`
metadata is not present, ensuring every asset counts as at least 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2025-11-23 00:57:36 -08:00
Alexander Brown
6d41e8b6e4 Feat: Load Image (from Outputs) support in Vue Nodes (#6836)
## Summary

Expose the Auto-refresh and manual refresh controls.
Fixes an issue with the Dropdown where it was index dependent so wasn't
updating correctly as new items came in.

## Screenshots

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6836-Feat-Load-Image-from-Outputs-support-in-Vue-Nodes-2b46d73d365081f1b44fcf2054d653da)
by [Unito](https://www.unito.io)
2025-11-22 23:59:00 -08:00
Terry Jia
274f77869b Fix: Opening mask editor on context menu (#6825)
## Summary

Fix issue of opening mask editor on context menu, reported in
https://github.com/Comfy-Org/ComfyUI_frontend/issues/6824

## Screenshots (if applicable)


https://github.com/user-attachments/assets/666d2769-d848-4b08-b54b-0cf5ed799b35

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6825-Fix-Opening-mask-editor-on-context-menu-2b36d73d3650810781a3c25a23ba488a)
by [Unito](https://www.unito.io)
2025-11-22 20:46:29 -05:00
Alexander Brown
f5c9f69678 Style: Fix the filter/search/sort controls on the Template Select Modal (#6835)
## Summary

Background and text colors.

## Screenshot
<img width="1186" height="148" alt="image"
src="https://github.com/user-attachments/assets/0ff3b0d5-6aae-45c5-9ebf-060a9973489c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6835-Style-Fix-the-filter-search-sort-controls-on-the-Template-Select-Modal-2b36d73d3650816b9850e1b9f7feb25e)
by [Unito](https://www.unito.io)
2025-11-22 15:30:14 -08:00
Alexander Brown
c1e237255a Fix: TextArea context menu (#6834)
## Summary

Allow the default browser context menu within textareas on Vue Nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6834-Fix-TextArea-context-menu-2b36d73d3650814e9706e76163dda59a)
by [Unito](https://www.unito.io)
2025-11-22 15:18:04 -08:00
AustinMroz
a91b9f288f When filtering by IO type, put wildcards last (#6829)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/628057b2-4844-490d-9899-fce82d8e2d58"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/2825f15f-c084-4e3d-8b22-cc0aa3febdce"
/> |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6829-When-filtering-by-IO-type-put-wildcards-last-2b36d73d36508196a41fed40b62f5b84)
by [Unito](https://www.unito.io)
2025-11-22 13:43:05 -07:00
Tristan Sommer
4adcf09cca GPU accelerated maskeditor rendering (#6767)
## GPU accelerated brush engine for the mask editor

- Full GPU acceleration using TypeGPU and type-safe shaders
- Catmull-Rom Spline Smoothing
- arc-length equidistant resampling
- much improved performance, even for huge images
- photoshop like opacity clamping for brush strokes
- much improved soft brushes
- fallback to CPU fully implemented, much improved CPU rendering
features as well

### Tested Browsers
- Chrome (fully supported)
- Safari 26 (fully supported, prev versions CPU fallback)
- Firefox (CPU fallback, flags needed for full support)



https://github.com/user-attachments/assets/b7b5cb8a-2290-4a95-ae7d-180e11fccdb0



https://github.com/user-attachments/assets/4297aaa5-f249-499a-9b74-869677f1c73b



https://github.com/user-attachments/assets/602b4783-3e2b-489e-bcb9-70534bcaac5e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6767-GPU-accelerated-maskeditor-rendering-2b16d73d3650818cb294e1fca03f6169)
by [Unito](https://www.unito.io)
2025-11-22 09:07:16 -05:00
Christian Byrne
1dbb3fc1b9 make vue node settings appear higher in the settings dialog (#6820)
makes setting groups/categories be sorted by highest internal setting
field `sortOrder` and adds high `sortOrder` values to the Vue Nodes
(Nodes 2.0) settings.


<img width="2282" height="1872" alt="Selection_2371"
src="https://github.com/user-attachments/assets/71e7e76b-4637-42b5-9f0c-2617622cda23"
/>
2025-11-21 20:32:18 -08:00
Alexander Brown
d6c5b33fce Fix: Vue <--> Litegraph scaling logic. (#6745)
Consistent names and order of operations. I think this fixes the
resizing too.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6745-refactor-Reorganizing-scaling-logic-2b06d73d365081eebc8ff93c87fa69fb)
by [Unito](https://www.unito.io)
2025-11-22 01:52:20 +00:00
Alexander Brown
f5608435b4 Fix: Clear apiKey on failed auth (#6816)
## Summary

Handles the case where an API key is structurally valid but not in our
DB.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6816-Fix-Clear-apiKey-on-failed-auth-2b26d73d3650817ab34edfa380795178)
by [Unito](https://www.unito.io)
2025-11-21 17:26:39 -07:00
Alexander Brown
9da82f47ef Feat: Alt+Drag to clone - Vue Nodes (#6789)
## Summary

Replicate the alt+drag to clone behavior present in litegraph.

## Changes

- **What**: Simplify the interaction/drag handling, now with less state!
- **What**: Alt+Click+Drag a node to clone it

## Screenshots (if applicable)



https://github.com/user-attachments/assets/469e33c2-de0c-4e64-a344-1e9d9339d528



<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6789-WIP-Alt-Drag-to-clone-Vue-Nodes-2b16d73d36508102a871ffe97ed2831f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-21 14:16:03 -08:00
Alexander Brown
a8d6f7baff Feat: Show Progress Text on Vue Nodes, Markdown for Preview as Text (#6805)
## Summary

Maps the progressText / text preview to a readonly Markdown widget.

## Review Focus

Anything else to tweak about this while I'm in here?

## Screenshots
<img width="1107" height="593" alt="image"
src="https://github.com/user-attachments/assets/a445c07a-2fcb-480c-976e-bda6ff343f14"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6805-Feat-Show-Progress-Text-on-Vue-Nodes-2b26d73d3650814d93c4f2fbf0e00095)
by [Unito](https://www.unito.io)
2025-11-21 13:54:53 -08:00
Christian Byrne
a7daa5071c fix: backport workflow fails when label description has single quote (#6814)
Fixes issue with backporting workflow when the target PR has a label
whose description has quotes
([example](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/19580741998/job/56077744515))

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6814-fix-backport-workflow-fails-when-label-description-has-single-quote-2b26d73d36508143bbefe6c4314fd370)
by [Unito](https://www.unito.io)
2025-11-21 14:23:36 -07:00
Jin Yi
639975b804 fix: Prevent multiple asset card popovers from opening simultaneously (#6808)
## Summary

Ensures only one MediaAssetCard popover is open at a time.

## Changes

- Added `hide()` method exposure in MoreButton component
- Implemented parent-level state management for tracking open popover
- Added VueUse `whenever` to auto-close other popovers when one opens

## Test Plan

- Open multiple asset cards' more menus
- Verify only one popover remains open at a time

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6808-fix-Prevent-multiple-asset-card-popovers-from-opening-simultaneously-2b26d73d365081e1b3f0d97b869cedc5)
by [Unito](https://www.unito.io)
2025-11-21 14:01:19 -07:00
Luke Mino-Altherr
ff0d385db8 [bugfix] Fix double-click required after pasting URL in upload model dialog (#6801)
## Summary
Fixed an issue where users had to click twice to continue after pasting
a URL in the upload model dialog - once to blur the input, then again to
click the button.

## Changes
- **What**: Replaced `UrlInput` with plain `InputText` in
`UploadModelUrlInput` to emit value immediately on input instead of only
on blur
- **Cleanup**: Moved URL cleaning/normalization to the `fetchMetadata`
handler, removed unused `disableValidation` prop from `UrlInput`
component

## Review Focus
- URL normalization logic in `useUploadModelWizard.ts`

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6801-bugfix-Fix-double-click-required-after-pasting-URL-in-upload-model-dialog-2b26d73d3650811881aed0cc064efcc7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-21 11:05:36 -08:00
Luke Mino-Altherr
6e2e591937 refactor: change model button terminology from Upload to Import (#6800)
## Summary
Changes user-facing text from "Upload" to "Import" for the model import
feature, as "Import" better describes importing models from external
sources like Civitai.

## Changes
- Updated button icon from `upload` to `package-plus`
- Changed all user-facing text in English locale from "Upload" to
"Import"

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6800-refactor-change-model-button-terminology-from-Upload-to-Import-2b26d73d365081059ce6e4a548f943ce)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 21:47:02 -08:00
Comfy Org PR Bot
27fcc4554f 1.33.5 (#6798)
Patch version increment to 1.33.5

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-20 21:06:15 -07:00
Alexander Brown
e563c1be75 hotfix: Stop clicks on the textarea from propagating to the node itself (#6788)
## Summary

Selecting text shouldn't drag the node.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6788-hotfix-Stop-clicks-on-the-textarea-from-propagating-to-the-node-itself-2b16d73d3650819c8d0dc427d5758580)
by [Unito](https://www.unito.io)
2025-11-20 21:05:24 -07:00
Jin Yi
5a297e520c fix: disabling queue button (#6797)
## Summary
- Removed the `disabled` prop binding from the queue button that was
incorrectly disabling it when there were missing nodes

## Changes
- Removed `:disabled="hasMissingNodes"` from ComfyQueueButton.vue:13

## Test plan
- [x] Verify queue button is no longer incorrectly disabled when there
are missing nodes
- [x] Verify queue functionality works as expected

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6797-fix-disabling-queue-button-2b26d73d3650810783cedd44fce757be)
by [Unito](https://www.unito.io)
2025-11-20 20:52:30 -07:00
Benjamin Lu
85bec5ee47 Use shared button components in queue overlay (#6793)
## Summary
- replace raw button elements in queue progress overlay UI with shared
IconButton/TextButton/IconTextButton components
- remove forced justify-start from IconTextButton base and add explicit
alignment where needed
- keep queue overlay actions consistent with button styling patterns

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6793-Use-shared-button-components-in-queue-overlay-2b26d73d3650814d9ebfebba74226036)
by [Unito](https://www.unito.io)
2025-11-20 20:18:33 -07:00
AustinMroz
bdf6d4dea2 Allow updating position and mode on missing nodes (#6792)
When a node is missing, attempts to serialize it will return the "last
known good" serialization to ensure that the node will still be
functional in the future (the node pack is installed/comfyui is
updated). However, this means even small and safe changes (like moving
the node out of the way or bypassing it so the workflow can be run) will
be discarded on reload.

This is resolved by including the updated position and mode when
returning early.

| Before | After |
| ------ | ----- |
| <img width="360" height="360" alt="before"
src="https://github.com/user-attachments/assets/8452682c-9531-4153-a258-158c634df3e8"
/> | <img width="360" height="360" alt="after"
src="https://github.com/user-attachments/assets/8825ce5e-c4a6-4f4a-be20-97e4aca69964"
/> |

Thanks to @Kosinkadink for bringing this up

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6792-Allow-updating-position-and-mode-on-missing-nodes-2b26d73d365081ed8c22fafe5348c49f)
by [Unito](https://www.unito.io)
2025-11-20 17:07:15 -08:00
Comfy Org PR Bot
b8a796212c 1.33.4 (#6791)
Patch version increment to 1.33.4

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-20 17:19:34 -07:00
AustinMroz
bc553f12be Add support for dynamic widgets (#6661)
Adds support for "dynamic combo" widgets where selecting a value on a
combo widget can cause other widgets or inputs to be created.


![dynamic-widgets_00001](https://github.com/user-attachments/assets/c797d008-f335-4d4e-9b2e-6fe4a7187ba7)

Includes a fairly large refactoring in litegraphService to remove
`#private` methods and cleanup some duplication in constructors for
subgraphNodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6661-Add-support-for-dynamic-widgets-2a96d73d3650817aa570c7babbaca2f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-11-20 16:53:59 -07:00
Jin Yi
6bb35d46c1 fix: Conditionally hide bottom border in missing nodes modal on non-cloud environments (#6779)
## Summary
- Conditionally hide the bottom border of the missing nodes modal
content when not running in cloud environment
- The footer is not visible in non-cloud environments, so the bottom
border was appearing disconnected

## Changes
- Added conditional `border-b-1` class based on `isCloud` flag in
`MissingNodesContent.vue`
- Keeps top border visible in all environments
- Bottom border only shows in cloud environment where footer is present

## Test plan
- [ ] Open missing nodes dialog in cloud environment - bottom border
should be visible
- [ ] Open missing nodes dialog in non-cloud environment - bottom border
should be hidden
- [ ] Verify top border remains visible in both environments

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6779-fix-Conditionally-hide-bottom-border-in-missing-nodes-modal-on-non-cloud-environments-2b16d73d365081cea1c8c98b11878045)
by [Unito](https://www.unito.io)
2025-11-20 16:52:08 -07:00
Alexander Piskun
68c38f0098 feat(api-nodes-pricing): add Nano-Banana-2 prices (#6781)
## Summary

Change pricing display for Nano Banana 1, and added pricing for Nano
Banana 2.

## Screenshots (if applicable)

<img width="2101" height="963" alt="image"
src="https://github.com/user-attachments/assets/78c922c6-f6d8-47c3-afeb-adf28deb5542"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6781-feat-api-nodes-pricing-add-Nano-Banana-2-prices-2b16d73d3650810a8e8dde8f01ba9f02)
by [Unito](https://www.unito.io)
2025-11-20 09:20:05 -08:00
Comfy Org PR Bot
236247f05f 1.33.3 (#6778)
Patch version increment to 1.33.3

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-20 00:56:00 -08:00
AustinMroz
87d6d18c57 Fix linear mode with vue (#6769)
Previously, entering linear mode from vue mode would cause all nodes to
be set to position 0.

This is fixed by ignoring resize updates with a contentRect of all 0s

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6769-Fix-linear-mode-with-vue-2b16d73d36508188964bcfb8b465dcb1)
by [Unito](https://www.unito.io)
2025-11-19 21:39:38 -08:00
Jin Yi
87106ccb95 [bugfix] Fix execute button incorrectly disabled on empty workflows (#6774)
## Summary

Fixes a bug where the queue/execute button was incorrectly disabled with
a warning icon when creating a new empty workflow, due to stale missing
nodes data persisting from a previous workflow.

## Root Cause

When switching from a workflow with missing nodes to an empty workflow,
the `getWorkflowPacks()` function in `useWorkflowPacks.ts` would return
early without clearing the `workflowPacks.value` ref, causing stale
missing node data to persist.

## Changes

- **`useWorkflowPacks.ts`**: Explicitly clear `workflowPacks.value = []`
when switching to empty workflow
- **`useMissingNodes.test.ts`**: Add test case to verify missing nodes
state clears when switching to empty workflow

## Test Plan

- [x] Added unit test covering the empty workflow scenario
- [x] All 20 unit tests pass
- [x] TypeScript type checking passes
- [x] Manual verification: Create workflow with missing nodes → Create
new empty workflow → Button should be enabled

## Before

1. Open workflow with missing nodes → Button disabled  (correct)
2. Create new empty workflow → Button still disabled  (bug)
3. Click valid workflow → Button enabled 

## After

1. Open workflow with missing nodes → Button disabled 
2. Create new empty workflow → Button enabled  (fixed)
3. Click valid workflow → Button enabled 

[screen-capture
(2).webm](https://github.com/user-attachments/assets/833355d6-6b4b-4e77-94b9-d7964454cfce)

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6774-bugfix-Fix-execute-button-incorrectly-disabled-on-empty-workflows-2b16d73d365081e3a050c3f7c0a20cc6)
by [Unito](https://www.unito.io)
2025-11-19 21:22:32 -08:00
AustinMroz
a20fb7d260 Allow unsetting widget labels (#6773)
https://github.com/user-attachments/assets/af344318-dac2-4611-b080-910cdfa1e87d

Quick followup to #6752
- Adds support for placeholder values in dialogService.prompt
- When label is unset, initial prompt is empty
- Display original widget name as placeholder
- When prompt returns an empty string (as opposed to null for a canceled
operation), remove widget label

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6773-Allow-unsetting-widget-labels-2b16d73d365081ae9f5dd085d0081733)
by [Unito](https://www.unito.io)
2025-11-19 21:14:32 -08:00
Christian Byrne
836cd7f9ba fix: node preview background color (#6768)
## Summary

Changes node previews to use background color from color palette instead
of the menu color.

| Before | After |
| ------ | ----- |
| <img width="1700" height="1248" alt="Selection_2345"
src="https://github.com/user-attachments/assets/cc1d0e97-9551-4f88-8e92-4fe6bcdd8c21"
/> | <img width="2144" height="1138" alt="Selection_2347"
src="https://github.com/user-attachments/assets/16e64be3-3623-4900-ad18-c599a1aee59b"
/> |

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/6576

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6768-fix-node-preview-background-color-2b16d73d365081868644e1e6b7c7e3be)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-11-19 21:41:25 -07:00
Luke Mino-Altherr
acd855601c [feat] Add Civitai model upload wizard (#6694)
## Summary
Adds a complete model upload workflow that allows users to import models
from Civitai URLs directly into their library.

## Changes
- **Multi-step wizard**: URL input → metadata confirmation → upload
progress
- **Components**: UploadModelDialog, UploadModelUrlInput,
UploadModelConfirmation, UploadModelProgress, UploadModelDialogHeader
- **API integration**: New assetService methods for metadata retrieval
and URL-based uploads
- **Model type management**: useModelTypes composable for fetching and
formatting available model types
- **UX improvements**: Optional validation bypass for UrlInput component
- **Localization**: 26 new i18n strings for the upload workflow

## Review Focus
- Error handling for failed uploads and metadata retrieval
- Model type detection and selection logic
- Dialog state management across multi-step workflow

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6694-feat-Add-Civitai-model-upload-wizard-2ab6d73d36508193b3b1dd67c7cc5a09)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-19 20:37:22 -08:00
Comfy Org PR Bot
423a2e76bc 1.33.2 (#6762)
Patch version increment to 1.33.2

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 20:29:36 -07:00
Benjamin Lu
26578981d4 Remove queue sidebar tab (#6724)
## Summary
- drop the queue sidebar entry, its component, and the supporting
composable so only the overlay-based queue UI remains
- clean up the related tests and keybindings so nothing references the
removed tab
- prune the unused queue task card components to keep the repo tidy
- remove unused queue sidebar translations and command strings across
all locales

## Testing
- pnpm typecheck
- pnpm lint:fix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6724-Remove-queue-sidebar-tab-2ae6d73d3650811db0d4c5ad4c5ffc8d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-19 19:50:24 -07:00
Simula_r
38fb53dca8 feat: LOD setting for LG and Vue (#6755)
## Summary

Create a composable for useRenderModeSetting that lets you set a setting
for either vue or litegraph and once set remembers each state
respectively.

```
  useRenderModeSetting(
    { setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
    shouldRenderVueNodes
  )
```

## Screenshots (if applicable)

<img width="1611" height="997" alt="image"
src="https://github.com/user-attachments/assets/621930f2-2d21-4c86-a46d-e3e292d4e012"
/>
<img width="1611" height="997" alt="chrome_Gr1V3P6sJi"
src="https://github.com/user-attachments/assets/eb63b747-487f-4f5e-8fcf-f0d2ff97b976"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6755-feat-LOD-setting-for-LG-and-Vue-2b16d73d365081cbbf09c292ee3c0e96)
by [Unito](https://www.unito.io)
2025-11-19 18:49:58 -08:00
AustinMroz
a832141a45 Support renaming widgets (#6752)
![widget-rename_00002](https://github.com/user-attachments/assets/65205d3e-2c03-480d-916e-0dae89ddbdd9)

Widget labels are saved by serializing the value on inputs. This
requires minor changes to ensure widgets inputs are serialized when
required.

Currently only exposed by right clicking on widgets directly. Should
probably be added to the subgraph config panel in the future.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6752-Support-renaming-widgets-2b06d73d36508196bff2e511c6e7b89b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 19:49:03 -07:00
Benjamin Lu
d1f0211b61 Desktop maintenance: unsafe base path warning (#6750)
Surface unsafe base path validation in the desktop maintenance view and
add an installation-fix auto-refresh after successful tasks.

<img width="1080" height="870" alt="image"
src="https://github.com/user-attachments/assets/26fe61be-fed8-47c0-a921-604f0af018f8"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6750-Desktop-maintenance-unsafe-base-path-warning-2b06d73d36508147aeb4d19d02bbf0f0)
by [Unito](https://www.unito.io)
2025-11-19 18:13:32 -08:00
Comfy Org PR Bot
cc42c2967c 1.33.1 (#6756)
Patch version increment to 1.33.1

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 17:52:38 -07:00
AustinMroz
bb51a5aa76 Add linear mode (#6670)
![linear-mode](https://github.com/user-attachments/assets/d1aa078a-00a8-4e71-86d5-ee929b269c90)

See also: #6642

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6670-Add-linear-mode-2aa6d73d365081d08887e4a6db3a8fa0)
by [Unito](https://www.unito.io)
2025-11-19 16:59:43 -07:00
Benjamin Lu
674d884e79 Remove queue cancel controls (#6723)
## Summary
- remove the interrupt/clear controls from the run button cluster
- drop the queue pending count dependencies that only fed those controls

This is at the request of product design, because this functionality is
being relocated to the queue progress overlay in bl-execution

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6723-Remove-queue-cancel-controls-2ae6d73d365081ddaa63ea9e3447ad7f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-11-19 16:54:27 -07:00
Benjamin Lu
6f89d9a9f8 Add errors for install dir edge cases (#6733)
## Summary
- show explicit validation errors when the install path lives inside the
desktop app bundle or updater cache
- include the new locale strings for these error prompts so the UI
surfaces actionable guidance

## Testing
- pnpm typecheck
- pnpm lint:fix

## Notes
Desktop types still need to be updated to include the new validation
flags; see https://github.com/Comfy-Org/desktop/pull/1400

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6733-Add-errors-for-install-dir-edge-cases-2af6d73d3650811bada6fc7dd72ecf68)
by [Unito](https://www.unito.io)
2025-11-19 15:53:35 -08:00
Comfy Org PR Bot
08b206f191 1.33.0 (#6753)
Minor version increment to 1.33.0

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-19 16:51:48 -07:00
164 changed files with 5831 additions and 3994 deletions

View File

@@ -1,5 +1,5 @@
# Description: When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Electron API Types'
description: 'When upstream electron API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Manager API Types'
description: 'When upstream ComfyUI-Manager API is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo
name: 'Api: Update Registry API Types'
description: 'When upstream comfy-api is updated, click dispatch to update the TypeScript type definitions in this repo'
on:
# Manual trigger

View File

@@ -1,5 +1,5 @@
# Description: Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq
name: "CI: JSON Validation"
description: "Validates JSON syntax in all tracked .json files (excluding tsconfig*.json) using jq"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Linting and code formatting validation for pull requests
name: "CI: Lint Format"
description: "Linting and code formatting validation for pull requests"
on:
pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Validates Python code in tools/devtools directory
name: "CI: Python Validation"
description: "Validates Python code in tools/devtools directory"
on:
pull_request:

View File

@@ -1,5 +1,5 @@
# Description: Deploys test results from forked PRs (forks can't access deployment secrets)
name: "CI: Tests E2E (Deploy for Forks)"
description: "Deploys test results from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:

View File

@@ -1,5 +1,5 @@
# Description: End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages
name: "CI: Tests E2E"
description: "End-to-end testing with Playwright across multiple browsers, deploys test reports to Cloudflare Pages"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Deploys Storybook previews from forked PRs (forks can't access deployment secrets)
name: "CI: Tests Storybook (Deploy for Forks)"
description: "Deploys Storybook previews from forked PRs (forks can't access deployment secrets)"
on:
workflow_run:

View File

@@ -1,10 +1,9 @@
# Description: Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages
name: "CI: Tests Storybook"
description: "Builds Storybook and runs visual regression testing via Chromatic, deploys previews to Cloudflare Pages"
on:
workflow_dispatch: # Allow manual triggering
pull_request:
branches: [main]
jobs:
# Post starting comment for non-forked PRs

View File

@@ -1,5 +1,5 @@
# Description: Unit and component testing with Vitest
name: "CI: Tests Unit"
description: "Unit and component testing with Vitest"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Validates YAML syntax and style using yamllint with relaxed rules
name: "CI: YAML Validation"
description: "Validates YAML syntax and style using yamllint with relaxed rules"
on:
push:

View File

@@ -1,5 +1,5 @@
# Description: Generates and updates translations for core ComfyUI components using OpenAI
name: "i18n: Update Core"
description: "Generates and updates translations for core ComfyUI components using OpenAI"
on:
# Manual dispatch for urgent translation updates
@@ -12,7 +12,10 @@ on:
jobs:
update-locales:
# Branch detection: Only run for manual dispatch or version-bump-* branches from main repo
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
if: |
github.event_name == 'workflow_dispatch'
|| (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
|| (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'sno-'))
runs-on: ubuntu-latest
steps:
- name: Checkout repository

View File

@@ -78,8 +78,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
fi
add_target() {

View File

@@ -1,5 +1,5 @@
# Description: AI-powered code review triggered by adding the 'claude-review' label to a PR
name: "PR: Claude Review"
description: "AI-powered code review triggered by adding the 'claude-review' label to a PR"
permissions:
contents: read

View File

@@ -148,10 +148,10 @@ jobs:
done
{
echo "results<<'EOF'"
echo "results<<EOF"
cat "$RESULTS_FILE"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
- name: Ensure release labels
if: steps.check_version.outputs.is_minor_bump == 'true'

View File

@@ -1,5 +1,5 @@
# Description: Manual workflow to increment package version with semantic versioning support
name: "Release: Version Bump"
description: "Manual workflow to increment package version with semantic versioning support"
on:
workflow_dispatch:

View File

@@ -1,5 +1,5 @@
# Description: Automated weekly documentation accuracy check and update via Claude
name: "Weekly Documentation Check"
description: "Automated weekly documentation accuracy check and update via Claude"
permissions:
contents: write

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

@@ -22,7 +22,11 @@
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
<p
v-if="statusText"
class="text-lg text-neutral-400"
data-testid="startup-status-text"
>
{{ statusText }}
</p>
</div>

View File

@@ -16,7 +16,6 @@ import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
QueueSidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
@@ -31,7 +30,6 @@ type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyMenu {
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _queueTab: QueueSidebarTab | null = null
private _topbar: Topbar | null = null
public readonly sideToolbar: Locator
@@ -60,11 +58,6 @@ class ComfyMenu {
return this._workflowsTab
}
get queueTab() {
this._queueTab ??= new QueueSidebarTab(this.page)
return this._queueTab
}
get topbar() {
this._topbar ??= new Topbar(this.page)
return this._topbar

View File

@@ -148,124 +148,3 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class QueueSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'queue')
}
get root() {
return this.page.locator('.sidebar-content-container', { hasText: 'Queue' })
}
get tasks() {
return this.root.locator('[data-virtual-grid-item]')
}
get visibleTasks() {
return this.tasks.locator('visible=true')
}
get clearButton() {
return this.root.locator('.clear-all-button')
}
get collapseTasksButton() {
return this.getToggleExpandButton(false)
}
get expandTasksButton() {
return this.getToggleExpandButton(true)
}
get noResultsPlaceholder() {
return this.root.locator('.no-results-placeholder')
}
get galleryImage() {
return this.page.locator('.galleria-image')
}
private getToggleExpandButton(isExpanded: boolean) {
const iconSelector = isExpanded ? '.pi-image' : '.pi-images'
return this.root.locator(`.toggle-expanded-button ${iconSelector}`)
}
async open() {
await super.open()
return this.root.waitFor({ state: 'visible' })
}
async close() {
await super.close()
await this.root.waitFor({ state: 'hidden' })
}
async expandTasks() {
await this.expandTasksButton.click()
await this.collapseTasksButton.waitFor({ state: 'visible' })
}
async collapseTasks() {
await this.collapseTasksButton.click()
await this.expandTasksButton.waitFor({ state: 'visible' })
}
async waitForTasks() {
return Promise.all([
this.tasks.first().waitFor({ state: 'visible' }),
this.tasks.last().waitFor({ state: 'visible' })
])
}
async scrollTasks(direction: 'up' | 'down') {
const scrollToEl =
direction === 'up' ? this.tasks.last() : this.tasks.first()
await scrollToEl.scrollIntoViewIfNeeded()
await this.waitForTasks()
}
async clearTasks() {
await this.clearButton.click()
const confirmButton = this.page.getByLabel('Delete')
await confirmButton.click()
await this.noResultsPlaceholder.waitFor({ state: 'visible' })
}
/** Set the width of the tab (out of 100). Must call before opening the tab */
async setTabWidth(width: number) {
if (width < 0 || width > 100) {
throw new Error('Width must be between 0 and 100')
}
return this.page.evaluate((width) => {
localStorage.setItem('queue', JSON.stringify([width, 100 - width]))
}, width)
}
getTaskPreviewButton(taskIndex: number) {
return this.tasks.nth(taskIndex).getByRole('button')
}
async openTaskPreview(taskIndex: number) {
const previewButton = this.getTaskPreviewButton(taskIndex)
await previewButton.click()
return this.galleryImage.waitFor({ state: 'visible' })
}
getGalleryImage(imageFilename: string) {
return this.galleryImage.and(this.page.getByAltText(imageFilename))
}
getTaskImage(imageFilename: string) {
return this.tasks.getByAltText(imageFilename)
}
/** Trigger the queue store and tasks to update */
async triggerTasksUpdate() {
await this.page.evaluate(() => {
window['app']['api'].dispatchCustomEvent('status', {
exec_info: { queue_remaining: 0 }
})
})
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,210 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe.skip('Queue sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('can display tasks', async ({ comfyPage }) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test('can display tasks after closing then opening', async ({
comfyPage
}) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.close()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test.describe('Virtual scroll', () => {
const layouts = [
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
]
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(50)
.setupRoutes()
})
layouts.forEach(({ description, width, rows, cols }) => {
const preRenderedRows = 1
const preRenderedTasks = preRenderedRows * cols * 2
const visibleTasks = rows * cols
const expectRenderLimit = visibleTasks + preRenderedTasks
test.describe(description, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.menu.queueTab.setTabWidth(width)
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('should not render items outside of view', async ({
comfyPage
}) => {
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should teardown items after scrolling away', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should re-render items after scrolling away then back', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
await comfyPage.menu.queueTab.scrollTasks('up')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
})
})
})
test.describe('Expand tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
// 2-item batch and 3-item batch -> 3 additional items when expanded
await comfyPage
.setupHistory()
.withTask(['example.webp', 'example.webp', 'example.webp'])
.withTask(['example.webp', 'example.webp'])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount + 3
)
})
test('can collapse flat tasks', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
await comfyPage.menu.queueTab.collapseTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount
)
})
})
test.describe('Clear tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(6)
.setupRoutes()
await comfyPage.menu.queueTab.open()
})
test('can clear all tasks', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
expect(
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
).toBe(true)
})
test('can load new tasks after clearing all', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
await comfyPage.menu.queueTab.close()
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
})
test.describe('Gallery', () => {
const firstImage = 'example.webp'
const secondImage = 'image32x32.webp'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask([secondImage])
.withTask([firstImage])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
await comfyPage.menu.queueTab.openTaskPreview(0)
})
test('displays gallery image after opening task preview', async ({
comfyPage
}) => {
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
comfyPage
}) => {
// Add a new task while the gallery is still open
const newImage = 'image64x64.webp'
comfyPage.setupHistory().withTask([newImage])
await comfyPage.menu.queueTab.triggerTasksUpdate()
await comfyPage.page.waitForTimeout(500)
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
const paths: {
description: string
path: ('Right' | 'Left')[]
end: string
}[] = [
{ description: 'Right', path: ['Right'], end: secondImage },
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
{ description: 'Left wrap', path: ['Left'], end: secondImage },
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
]
paths.forEach(({ description, path, end }) => {
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
for (const direction of path)
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
delay: 256
})
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 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: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -9,9 +9,9 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
})
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 81 KiB

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'

View File

@@ -34,7 +34,9 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Used by Playwright's Babel configuration for i18n tests
'babel-plugin-module-resolver'
],
ignore: [
// Auto generated manager types

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.8",
"version": "1.33.8",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -75,6 +75,8 @@
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"@vue/test-utils": "catalog:",
"@webgpu/types": "catalog:",
"babel-plugin-module-resolver": "catalog:",
"cross-env": "catalog:",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
@@ -112,6 +114,7 @@
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "^11.1.0",
"vite": "catalog:",
@@ -162,7 +165,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",
@@ -176,6 +178,7 @@
"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
"vue": "catalog:",
"vue-i18n": "catalog:",
"vue-router": "catalog:",

View File

@@ -1,6 +1,10 @@
import { defineConfig } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
export default defineConfig({
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const config = defineConfig({
testDir: './scripts',
use: {
baseURL: 'http://localhost:5173',
@@ -9,5 +13,49 @@ export default defineConfig({
reporter: 'list',
workers: 1,
timeout: 60000,
testMatch: /collect-i18n-.*\.ts/
testMatch: /collect-i18n-.*\.ts/,
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 120000
}
})
// Add Babel plugins for handling TypeScript and Vite defines
;(config as any)['@playwright/test'] = {
babelPlugins: [
// Module resolver for @ alias
[
'babel-plugin-module-resolver',
{
root: ['./'],
alias: { '@': './src' }
}
],
// TypeScript transformation with declare fields support
[
'@babel/plugin-transform-typescript',
{
allowDeclareFields: true,
onlyRemoveTypeImports: true
}
],
// Custom plugin to replace Vite define constants
[path.join(__dirname, 'scripts/babel-plugin-vite-define.cjs')],
// Inject browser globals setup for i18n collection tests
[
path.join(__dirname, 'scripts/babel-plugin-inject-globals.cjs'),
{
filenamePattern: 'collect-i18n-',
setupFile: './setup-i18n-globals.mjs'
}
]
]
}
export default config

205
pnpm-lock.yaml generated
View File

@@ -126,12 +126,18 @@ catalogs:
'@vueuse/integrations':
specifier: ^13.9.0
version: 13.9.0
'@webgpu/types':
specifier: ^0.1.66
version: 0.1.66
algoliasearch:
specifier: ^5.21.0
version: 5.21.0
axios:
specifier: ^1.8.2
version: 1.11.0
babel-plugin-module-resolver:
specifier: ^5.0.2
version: 5.0.2
cross-env:
specifier: ^10.1.0
version: 10.1.0
@@ -246,6 +252,9 @@ catalogs:
tw-animate-css:
specifier: ^1.3.8
version: 1.3.8
typegpu:
specifier: ^0.8.2
version: 0.8.2
typescript:
specifier: ^5.9.2
version: 5.9.2
@@ -255,6 +264,9 @@ catalogs:
unplugin-icons:
specifier: ^0.22.0
version: 0.22.0
unplugin-typegpu:
specifier: 0.8.0
version: 0.8.0
unplugin-vue-components:
specifier: ^0.28.0
version: 0.28.0
@@ -422,9 +434,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
@@ -464,6 +473,9 @@ importers:
tiptap-markdown:
specifier: ^0.8.10
version: 0.8.10(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
typegpu:
specifier: 'catalog:'
version: 0.8.2
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.2)
@@ -561,6 +573,12 @@ importers:
'@vue/test-utils':
specifier: 'catalog:'
version: 2.4.6
'@webgpu/types':
specifier: 'catalog:'
version: 0.1.66
babel-plugin-module-resolver:
specifier: 'catalog:'
version: 5.0.2
cross-env:
specifier: 'catalog:'
version: 10.1.0
@@ -672,6 +690,9 @@ importers:
unplugin-icons:
specifier: 'catalog:'
version: 0.22.0(@vue/compiler-sfc@3.5.13)
unplugin-typegpu:
specifier: 'catalog:'
version: 0.8.0(typegpu@0.8.2)
unplugin-vue-components:
specifier: 'catalog:'
version: 0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2))
@@ -1431,6 +1452,10 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/standalone@7.28.5':
resolution: {integrity: sha512-1DViPYJpRU50irpGMfLBQ9B4kyfQuL6X7SS7pwTeWeZX0mNkjzPi0XFqxCjSdddZXUQy4AhnQnnesA/ZHnvAdw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -3790,8 +3815,8 @@ packages:
peerDependencies:
vue: ^3.5.0
'@webgpu/types@0.1.51':
resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
'@webgpu/types@0.1.66':
resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==}
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
@@ -4045,6 +4070,9 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
babel-plugin-module-resolver@5.0.2:
resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==}
babel-plugin-polyfill-corejs2@0.4.14:
resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==}
peerDependencies:
@@ -5054,9 +5082,16 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-babel-config@2.1.2:
resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==}
find-package-json@1.2.0:
resolution: {integrity: sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw==}
find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -5970,6 +6005,10 @@ packages:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@3.0.0:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -6038,6 +6077,10 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
@@ -6509,10 +6552,18 @@ packages:
oxlint-tsgolint:
optional: true
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -6521,6 +6572,10 @@ packages:
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
engines: {node: '>=18'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.0:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
@@ -6569,6 +6624,10 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -6650,6 +6709,10 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
pkg-up@3.1.0:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
playwright-core@1.52.0:
resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==}
engines: {node: '>=18'}
@@ -6986,6 +7049,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@4.1.8:
resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -7001,11 +7067,6 @@ packages:
resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==}
engines: {node: '>=10'}
resolve@1.22.10:
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
engines: {node: '>= 0.4'}
hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -7411,6 +7472,14 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyest-for-wgsl@0.1.3:
resolution: {integrity: sha512-Wm5ADG1UyDxykf42S1gLYP4U9e1QP/TdtJeovQi6y68zttpiFLKqQGioHmPs9Mjysh7YMSAr/Lpuk0cD2MVdGA==}
engines: {node: '>=12.20.0'}
tinyest@0.1.2:
resolution: {integrity: sha512-aHRmouyowIq1P5jrTF+YK6pGX+WuvFtSCLbqk91yHnU3SWQRIcNIamZLM5XF6lLqB13AWz0PGPXRff2QGDsxIg==}
engines: {node: '>=12.20.0'}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -7537,6 +7606,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-binary@4.3.2:
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
typegpu@0.8.2:
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
engines: {node: '>=12.20.0'}
typescript-eslint@8.44.0:
resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7641,6 +7717,11 @@ packages:
vue-template-es2015-compiler:
optional: true
unplugin-typegpu@0.8.0:
resolution: {integrity: sha512-VJHdXSXGOkAx0WhwFczhVUjAI6HyDkrQXk20HnwyuzIE3FdqE5l9sJTCYZzoVGo3z8i/IA5TMHCDzzP0Bc97Cw==}
peerDependencies:
typegpu: ^0.8.0
unplugin-vue-components@0.28.0:
resolution: {integrity: sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==}
engines: {node: '>=14'}
@@ -7831,8 +7912,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==}
@@ -8350,7 +8431,7 @@ snapshots:
'@babel/helper-plugin-utils': 7.27.1
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
@@ -8969,6 +9050,8 @@ snapshots:
'@babel/runtime@7.28.4': {}
'@babel/standalone@7.28.5': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -9838,7 +9921,7 @@ snapshots:
'@rushstack/ts-command-line': 5.0.3(@types/node@20.14.10)
lodash: 4.17.21
minimatch: 10.0.3
resolve: 1.22.10
resolve: 1.22.11
semver: 7.5.4
source-map: 0.6.1
typescript: 5.8.2
@@ -9850,7 +9933,7 @@ snapshots:
'@microsoft/tsdoc': 0.15.1
ajv: 8.12.0
jju: 1.4.0
resolve: 1.22.10
resolve: 1.22.11
'@microsoft/tsdoc@0.15.1': {}
@@ -10439,14 +10522,14 @@ snapshots:
fs-extra: 11.3.2
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.10
resolve: 1.22.11
semver: 7.5.4
optionalDependencies:
'@types/node': 20.14.10
'@rushstack/rig-package@0.5.3':
dependencies:
resolve: 1.22.10
resolve: 1.22.11
strip-json-comments: 3.1.1
'@rushstack/terminal@0.16.0(@types/node@20.14.10)':
@@ -10633,7 +10716,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:
@@ -11016,7 +11099,7 @@ snapshots:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
'@types/webxr': 0.5.20
'@webgpu/types': 0.1.51
'@webgpu/types': 0.1.66
fflate: 0.8.2
meshoptimizer: 0.18.1
@@ -11519,7 +11602,7 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.9.2)
'@webgpu/types@0.1.51': {}
'@webgpu/types@0.1.66': {}
'@xstate/fsm@1.6.5': {}
@@ -11802,7 +11885,15 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.4
cosmiconfig: 7.1.0
resolve: 1.22.10
resolve: 1.22.11
babel-plugin-module-resolver@5.0.2:
dependencies:
find-babel-config: 2.1.2
glob: 9.3.5
pkg-up: 3.1.0
reselect: 4.1.8
resolve: 1.22.11
babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.27.1):
dependencies:
@@ -12961,8 +13052,16 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-babel-config@2.1.2:
dependencies:
json5: 2.2.3
find-package-json@1.2.0: {}
find-up@3.0.0:
dependencies:
locate-path: 3.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -13943,6 +14042,11 @@ snapshots:
pkg-types: 2.3.0
quansync: 0.2.11
locate-path@3.0.0:
dependencies:
p-locate: 3.0.0
path-exists: 3.0.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -14000,6 +14104,10 @@ snapshots:
lz-string@1.5.0: {}
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.19
magic-string@0.30.19:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -14752,16 +14860,26 @@ snapshots:
'@oxlint/win32-x64': 1.28.0
oxlint-tsgolint: 0.4.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@3.0.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-map@7.0.3: {}
p-try@2.2.0: {}
package-json-from-dist@1.0.0: {}
package-json@10.0.1:
@@ -14812,6 +14930,8 @@ snapshots:
path-browserify@1.0.1: {}
path-exists@3.0.0: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
@@ -14872,6 +14992,10 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
pkg-up@3.1.0:
dependencies:
find-up: 3.0.0
playwright-core@1.52.0: {}
playwright@1.52.0:
@@ -15112,7 +15236,7 @@ snapshots:
jstransformer: 1.0.0
pug-error: 2.1.0
pug-walk: 2.0.0
resolve: 1.22.10
resolve: 1.22.11
pug-lexer@5.0.1:
dependencies:
@@ -15349,6 +15473,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@4.1.8: {}
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -15357,18 +15483,11 @@ snapshots:
resolve.exports@2.0.3: {}
resolve@1.22.10:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
optional: true
restore-cursor@3.1.0:
dependencies:
@@ -15864,6 +15983,12 @@ snapshots:
tinybench@2.9.0: {}
tinyest-for-wgsl@0.1.3:
dependencies:
tinyest: 0.1.2
tinyest@0.1.2: {}
tinyexec@0.3.2: {}
tinyexec@1.0.1: {}
@@ -15995,6 +16120,13 @@ snapshots:
reflect.getprototypeof: 1.0.10
optional: true
typed-binary@4.3.2: {}
typegpu@0.8.2:
dependencies:
tinyest: 0.1.2
typed-binary: 4.3.2
typescript-eslint@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2):
dependencies:
'@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.35.0(jiti@2.4.2))(typescript@5.9.2)
@@ -16090,6 +16222,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-typegpu@0.8.0(typegpu@0.8.2):
dependencies:
'@babel/standalone': 7.28.5
defu: 6.1.4
estree-walker: 3.0.3
magic-string-ast: 1.0.3
pathe: 2.0.3
picomatch: 4.0.3
tinyest: 0.1.2
tinyest-for-wgsl: 0.1.3
typegpu: 0.8.2
unplugin: 2.3.5
unplugin-vue-components@0.28.0(@babel/parser@7.28.4)(rollup@4.22.4)(vue@3.5.13(typescript@5.9.2)):
dependencies:
'@antfu/utils': 0.7.10
@@ -16370,7 +16515,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

@@ -43,8 +43,10 @@ catalog:
'@vue/test-utils': ^2.4.6
'@vueuse/core': ^11.0.0
'@vueuse/integrations': ^13.9.0
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
babel-plugin-module-resolver: ^5.0.2
cross-env: ^10.1.0
dotenv: ^16.4.5
eslint: ^9.34.0
@@ -83,9 +85,11 @@ catalog:
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
typescript: ^5.9.2
typescript-eslint: ^8.44.0
unplugin-icons: ^0.22.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^0.28.0
vite: ^5.4.19
vite-plugin-dts: ^4.5.4

View File

@@ -0,0 +1,51 @@
/**
* Babel plugin to inject global setup imports into specific test files
*
* This plugin automatically adds an import for browser globals setup
* at the beginning of files matching a specific pattern
*/
const nodePath = require('path')
module.exports = function (babel, options = {}) {
const { filenamePattern = 'collect-i18n-', setupFile = './setup-i18n-globals.mjs' } = options
return {
name: 'babel-plugin-inject-globals',
visitor: {
Program: {
enter(path, state) {
const filename = state.file.opts.filename
// Only process files matching the pattern
if (!filename || !filename.includes(filenamePattern)) {
return
}
// Check if setup import already exists
const hasSetupImport = path.node.body.some(
(node) =>
node.type === 'ImportDeclaration' &&
node.source.value.includes('setup-i18n-globals')
)
if (hasSetupImport) {
return
}
// Create the import statement
const importDeclaration = babel.types.importDeclaration(
[],
babel.types.stringLiteral(setupFile)
)
// Add the import at the beginning of the file
path.node.body.unshift(importDeclaration)
console.log(`[babel-plugin-inject-globals] Injected setup into ${nodePath.basename(filename)}`)
}
}
}
}
}

View File

@@ -0,0 +1,148 @@
/**
* Babel plugin to replace Vite define constants during Playwright test compilation
*
* This plugin reads the Vite config and replaces compile-time constants like
* __DISTRIBUTION__, __COMFYUI_FRONTEND_VERSION__, etc. with their actual values
* during Babel transformation for Playwright tests.
*/
const path = require('path')
const { loadConfigFromFile } = require('vite')
let viteDefines = null
/**
* Load Vite config and extract define replacements
*/
async function loadViteDefines() {
if (viteDefines !== null) {
return viteDefines
}
try {
const configFile = path.resolve(__dirname, '../vite.config.mts')
const result = await loadConfigFromFile(
{ command: 'build', mode: 'production' },
configFile
)
if (result && result.config && result.config.define) {
viteDefines = result.config.define
console.log('[babel-plugin-vite-define] Loaded Vite defines:', Object.keys(viteDefines))
} else {
viteDefines = {}
console.warn('[babel-plugin-vite-define] No defines found in Vite config')
}
} catch (error) {
viteDefines = {}
console.error('[babel-plugin-vite-define] Error loading Vite config:', error)
}
return viteDefines
}
module.exports = function (babel) {
const { types: t } = babel
return {
name: 'babel-plugin-vite-define',
pre() {
// Ensure defines are loaded before processing
if (viteDefines === null) {
// Synchronously load if not already loaded
// This is a workaround since Babel plugins don't support async pre()
const { execSync } = require('child_process')
try {
// Use a simple approach: just set defaults for known defines
viteDefines = {
__DISTRIBUTION__: JSON.stringify('localhost'),
__COMFYUI_FRONTEND_VERSION__: JSON.stringify('0.0.0-dev'),
__SENTRY_ENABLED__: JSON.stringify(false),
__SENTRY_DSN__: JSON.stringify(''),
__ALGOLIA_APP_ID__: JSON.stringify(''),
__ALGOLIA_API_KEY__: JSON.stringify(''),
__USE_PROD_CONFIG__: false
}
console.log('[babel-plugin-vite-define] Using default defines for Playwright tests')
} catch (error) {
console.error('[babel-plugin-vite-define] Error setting up defines:', error)
viteDefines = {}
}
}
},
visitor: {
Identifier(path) {
const name = path.node.name
// Skip if not a define constant
if (!viteDefines || !(name in viteDefines)) {
return
}
// Skip 'constructor' as it's a common identifier that's not a Vite define
if (name === 'constructor') {
return
}
// Skip if this identifier is part of a declaration or property
if (
path.isBindingIdentifier() ||
path.parent.type === 'MemberExpression' && path.parent.property === path.node ||
path.parent.type === 'ObjectProperty' && path.parent.key === path.node ||
path.parent.type === 'ClassMethod' ||
path.parent.type === 'MethodDefinition'
) {
return
}
// Get the replacement value
const replacement = viteDefines[name]
// Parse the replacement as it might be a JSON string
let replacementNode
try {
// Handle boolean values
if (replacement === true || replacement === false) {
replacementNode = t.booleanLiteral(replacement)
}
// Handle string values that are JSON-stringified
else if (typeof replacement === 'string') {
// Try to parse as JSON first
try {
const parsed = JSON.parse(replacement)
if (typeof parsed === 'string') {
replacementNode = t.stringLiteral(parsed)
} else if (typeof parsed === 'number') {
replacementNode = t.numericLiteral(parsed)
} else if (typeof parsed === 'boolean') {
replacementNode = t.booleanLiteral(parsed)
} else if (parsed === null) {
replacementNode = t.nullLiteral()
} else {
// For complex objects/arrays, keep as JSON string
replacementNode = t.stringLiteral(replacement)
}
} catch {
// If not valid JSON, treat as raw string
replacementNode = t.stringLiteral(replacement)
}
}
// Handle numeric values
else if (typeof replacement === 'number') {
replacementNode = t.numericLiteral(replacement)
}
else {
console.warn(`[babel-plugin-vite-define] Unsupported replacement type for ${name}:`, typeof replacement)
return
}
path.replaceWith(replacementNode)
} catch (error) {
console.error(`[babel-plugin-vite-define] Error replacing ${name}:`, error)
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
/**
* Setup browser globals for i18n collection in Node.js environment
*
* This file is imported at the top of i18n collection test files to provide
* browser globals that are referenced in the codebase but not available in Node.js
*/
import { JSDOM } from 'jsdom'
// Create a minimal JSDOM instance
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost:5173',
pretendToBeVisual: true,
resources: 'usable'
})
// Set up global window and document
global.window = dom.window
global.document = dom.window.document
// Use defineProperty for read-only globals
Object.defineProperty(global, 'navigator', {
value: dom.window.navigator,
writable: true,
configurable: true
})
// Set up other common browser globals
global.HTMLElement = dom.window.HTMLElement
global.Element = dom.window.Element
global.Node = dom.window.Node
global.NodeList = dom.window.NodeList
global.MutationObserver = dom.window.MutationObserver
global.ResizeObserver = dom.window.ResizeObserver || class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
global.IntersectionObserver = dom.window.IntersectionObserver || class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
// Set up basic localStorage and sessionStorage
global.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
}
global.sessionStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {}
}
// Mock requestAnimationFrame
global.requestAnimationFrame = (callback) => setTimeout(callback, 0)
global.cancelAnimationFrame = (id) => clearTimeout(id)

View File

@@ -10,7 +10,6 @@
severity="primary"
size="small"
:model="queueModeMenuItems"
:disabled="hasMissingNodes"
data-testid="queue-button"
@click="queuePrompt"
>
@@ -32,46 +31,12 @@
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="{
value: $t('menu.interrupt'),
showDelay: 600
}"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
:aria-label="$t('menu.interrupt')"
@click="() => commandStore.execute('Comfy.Interrupt')"
/>
<Button
v-tooltip.bottom="{
value: $t('sideToolbar.queueTab.clearPendingTasks'),
showDelay: 600
}"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
@click="
() => {
if (queueCountStore.count.value > 1) {
commandStore.execute('Comfy.ClearPendingTasks')
}
queueMode = 'disabled'
}
"
/>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
@@ -80,17 +45,13 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import {
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
@@ -145,11 +106,6 @@ const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
)
const iconClass = computed(() => {
if (hasMissingNodes.value) {
return 'icon-[lucide--triangle-alert]'

View File

@@ -68,4 +68,8 @@ const toggle = (event: Event) => {
const hide = () => {
popover.value?.hide()
}
defineExpose({
hide
})
</script>

View File

@@ -35,7 +35,6 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>()
const emit = defineEmits<{
@@ -102,8 +101,6 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
const validateUrl = async (value: string) => {
if (props.disableValidation) return
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -4,6 +4,7 @@
<InputText
ref="inputRef"
v-model="inputValue"
:placeholder
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
@@ -28,6 +29,7 @@ const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)

View File

@@ -83,7 +83,7 @@
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
tabindex="0"
:tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"

View File

@@ -26,6 +26,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
getEffectiveBrushSize,
getEffectiveHardness
} from '@/composables/maskeditor/brushUtils'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
@@ -36,11 +40,14 @@ const { containerRef } = defineProps<{
const store = useMaskEditorStore()
const brushOpacity = computed(() => {
return store.brushVisible ? '1' : '0'
return store.brushVisible ? 1 : 0
})
const brushRadius = computed(() => {
return store.brushSettings.size * store.zoomRatio
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
return effectiveSize * store.zoomRatio
})
const brushSize = computed(() => {
@@ -78,19 +85,26 @@ const gradientVisible = computed(() => {
})
const gradientBackground = computed(() => {
const size = store.brushSettings.size
const hardness = store.brushSettings.hardness
const effectiveSize = getEffectiveBrushSize(size, hardness)
const effectiveHardness = getEffectiveHardness(size, hardness, effectiveSize)
if (hardness === 1) {
if (effectiveHardness === 1) {
return 'rgba(255, 0, 0, 0.5)'
}
const midStop = hardness * 100
const midStop = effectiveHardness * 100
const outerStop = 100
// Add an intermediate stop to approximate the squared falloff
// At 50% of the fade region, squared falloff is 0.25 (relative to max)
const fadeMidStop = midStop + (outerStop - midStop) * 0.5
return `radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0.5) ${midStop}%,
rgba(255, 0, 0, 0.125) ${fadeMidStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)`
})

View File

@@ -55,7 +55,7 @@
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="100"
:max="500"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
@@ -80,12 +80,12 @@
/>
<SliderControl
:label="t('maskEditor.smoothingPrecision')"
label="Stepsize"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.smoothingPrecision"
@update:model-value="onSmoothingPrecisionChange"
:model-value="store.brushSettings.stepSize"
@update:model-value="onStepSizeChange"
/>
</div>
</template>
@@ -119,8 +119,8 @@ const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onSmoothingPrecisionChange = (value: number) => {
store.setBrushSmoothingPrecision(value)
const onStepSizeChange = (value: number) => {
store.setBrushStepSize(value)
}
const resetToDefault = () => {

View File

@@ -12,19 +12,28 @@
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-0"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-10"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full"
class="absolute top-0 left-0 w-full h-full z-30"
@contextmenu.prevent
/>
<!-- GPU Preview Canvas -->
<canvas
ref="gpuCanvasRef"
class="absolute top-0 left-0 w-full h-full pointer-events-none"
:class="{
'z-20': store.activeLayer === 'rgb',
'z-40': store.activeLayer === 'mask'
}"
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div>
@@ -87,6 +96,7 @@ const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>()
const gpuCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
@@ -97,7 +107,7 @@ const initialized = ref(false)
const keyboard = useKeyboard()
const panZoom = usePanAndZoom()
let toolManager: ReturnType<typeof useToolManager> | null = null
const toolManager = useToolManager(keyboard, panZoom)
let resizeObserver: ResizeObserver | null = null
@@ -135,8 +145,6 @@ const initUI = async () => {
try {
await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
@@ -149,6 +157,18 @@ const initUI = async () => {
store.canvasHistory.saveInitialState()
// Initialize GPU resources
if (toolManager.brushDrawing) {
await toolManager.brushDrawing.initGPUResources()
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
// Match preview canvas resolution to mask canvas
gpuCanvasRef.value.width = maskCanvasRef.value.width
gpuCanvasRef.value.height = maskCanvasRef.value.height
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
}
}
initialized.value = true
} catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error)
@@ -172,7 +192,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
toolManager?.brushDrawing.saveBrushSettings()
toolManager.brushDrawing.saveBrushSettings()
keyboard?.removeListeners()

View File

@@ -102,6 +102,7 @@ const onInvert = () => {
const onClear = () => {
canvasTools.clearMask()
store.triggerClear()
}
const handleSave = async () => {

View File

@@ -44,6 +44,7 @@
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
</nav>
@@ -56,6 +57,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'

View File

@@ -0,0 +1,39 @@
<template>
<SidebarIcon
:label="$t('g.settings')"
:tooltip="tooltipText"
@click="showSettingsDialog"
>
<template #icon>
<i class="icon-[lucide--settings]" />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import SidebarIcon from './SidebarIcon.vue'
const { t } = useI18n()
const { getCommand, formatKeySequence } = useCommandStore()
const command = getCommand('Comfy.ShowSettingsDialog')
const tooltipText = computed(
() => `${t('g.settings')} (${formatKeySequence(command)})`
)
/**
* Toggle keyboard shortcuts panel and track UI button click.
*/
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
})
}
</script>

View File

@@ -85,10 +85,13 @@
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-popover-id="openPopoverId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
/>
</template>
</VirtualGrid>
@@ -199,6 +202,9 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
@@ -208,7 +214,7 @@ const shouldShowDeleteButton = computed(() => {
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
return typeof count === 'number' && count > 0 ? count : 1
}
const shouldShowOutputCount = (item: AssetItem): boolean => {

View File

@@ -1,289 +0,0 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
v-tooltip.bottom="$t(`sideToolbar.queueTab.${imageFit}ImagePreview`)"
:icon="
imageFit === 'cover'
? 'pi pi-arrow-down-left-and-arrow-up-right-to-center'
: 'pi pi-arrow-up-right-and-arrow-down-left-from-center'
"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleImageFit"
/>
<Button
v-if="isInFolderView"
v-tooltip.bottom="$t('sideToolbar.queueTab.backToAllTasks')"
icon="pi pi-arrow-left"
text
severity="secondary"
class="back-button"
@click="exitFolderView"
/>
<template v-else>
<Button
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
:icon="isExpanded ? 'pi pi-images' : 'pi pi-image'"
text
severity="secondary"
class="toggle-expanded-button"
@click="toggleExpanded"
/>
<Button
v-if="queueStore.hasPendingTasks"
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
severity="danger"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
class="clear-all-button"
@click="confirmRemoveAll($event)"
/>
</template>
</template>
<template #body>
<VirtualGrid
v-if="allTasks?.length"
:items="allTasks"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
gap: '0.5rem'
}"
>
<template #item="{ item }">
<TaskItem
:task="item"
:is-flat-task="isExpanded || isInFolderView"
@contextmenu="handleContextMenu"
@preview="handlePreview"
@task-output-length-clicked="enterFolderView($event)"
/>
</template>
</VirtualGrid>
<div v-else-if="queueStore.isLoading">
<ProgressSpinner
style="width: 50px; left: 50%; transform: translateX(-50%)"
/>
</div>
<div v-else>
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="$t('g.noTasksFound')"
:message="$t('g.noTasksFoundMessage')"
/>
</div>
</template>
</SidebarTabTemplate>
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
v-model:active-index="galleryActiveIndex"
:all-gallery-items="allGalleryItems"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import ProgressSpinner from 'primevue/progressspinner'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useCommandStore } from '@/stores/commandStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import { useQueueStore } from '@/stores/queueStore'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
import ResultGallery from './queue/ResultGallery.vue'
import TaskItem from './queue/TaskItem.vue'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const galleryActiveIndex = ref(-1)
const allGalleryItems = shallowRef<ResultItemImpl[]>([])
// Folder view: only show outputs from a single selected task.
const folderTask = ref<TaskItemImpl | null>(null)
const isInFolderView = computed(() => folderTask.value !== null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const allTasks = computed(() =>
isInFolderView.value
? folderTask.value
? folderTask.value.flatten()
: []
: isExpanded.value
? queueStore.flatTasks
: queueStore.tasks
)
const updateGalleryItems = () => {
allGalleryItems.value = allTasks.value.flatMap((task: TaskItemImpl) => {
const previewOutput = task.previewOutput
return previewOutput ? [previewOutput] : []
})
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
const removeTask = async (task: TaskItemImpl) => {
if (task.isRunning) {
await api.interrupt(task.promptId)
}
await queueStore.delete(task)
}
const removeAllTasks = async () => {
await queueStore.clear()
}
const confirmRemoveAll = (event: Event) => {
confirm.require({
target: event.currentTarget as HTMLElement,
message: 'Do you want to delete all tasks?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
await removeAllTasks()
toast.add({
severity: 'info',
summary: 'Confirmed',
detail: 'Tasks deleted',
life: 3000
})
}
})
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: isCloud
? !menuTargetTask.value?.isHistory
: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
]
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
items.push({
label: t('g.setAsBackground'),
icon: 'pi pi-image',
command: () => {
const url = menuTargetTask.value?.previewOutput?.url
if (url) {
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
}
}
})
}
return items
})
const handleContextMenu = ({
task,
event,
node
}: {
task: TaskItemImpl
event: Event
node: ComfyNode | null
}) => {
menuTargetTask.value = task
menuTargetNode.value = node
menu.value?.show(event)
}
const handlePreview = (task: TaskItemImpl) => {
updateGalleryItems()
galleryActiveIndex.value = allGalleryItems.value.findIndex(
(item) => item.url === task.previewOutput?.url
)
}
const enterFolderView = (task: TaskItemImpl) => {
folderTask.value = task
}
const exitFolderView = () => {
folderTask.value = null
}
const toggleImageFit = async () => {
await settingStore.set(
IMAGE_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
}
watch(allTasks, () => {
const isGalleryOpen = galleryActiveIndex.value !== -1
if (!isGalleryOpen) return
const prevLength = allGalleryItems.value.length
updateGalleryItems()
const lengthChange = allGalleryItems.value.length - prevLength
if (!lengthChange) return
const newIndex = galleryActiveIndex.value + lengthChange
galleryActiveIndex.value = Math.max(0, newIndex)
})
</script>

View File

@@ -1,79 +0,0 @@
<template>
<div
ref="resultContainer"
class="result-container"
@click="handlePreviewClick"
>
<ComfyImage
v-if="result.isImage"
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
:alt="result.filename"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const emit = defineEmits<{
(e: 'preview', result: ResultItemImpl): void
}>()
const resultContainer = ref<HTMLElement | null>(null)
const settingStore = useSettingStore()
const imageFit = computed<string>(() =>
settingStore.get('Comfy.Queue.ImageFit')
)
const handlePreviewClick = () => {
if (props.result.supportsPreview) {
emit('preview', props.result)
}
}
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value?.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
})
</script>
<style scoped>
.result-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease;
}
.result-container:hover {
transform: scale(1.02);
}
</style>

View File

@@ -1,271 +0,0 @@
<template>
<div class="task-item" @contextmenu="handleContextMenu">
<div class="task-result-preview">
<template
v-if="
task.displayStatus === TaskItemDisplayStatus.Completed ||
cancelledWithResults
"
>
<ResultItem
v-if="flatOutputs.length && coverResult"
:result="coverResult"
@preview="handlePreview"
/>
</template>
<template v-if="task.displayStatus === TaskItemDisplayStatus.Running">
<i v-if="!progressPreviewBlobUrl" class="pi pi-spin pi-spinner" />
<img
v-else
:src="progressPreviewBlobUrl"
class="progress-preview-img"
/>
</template>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
<i
v-else-if="cancelledWithoutResults"
class="pi pi-exclamation-triangle"
/>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Failed"
class="pi pi-exclamation-circle"
/>
</div>
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Button
class="task-node-link"
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="
() => {
if (!node) return
litegraphService.goToNode(node.id)
}
"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)" />
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
</span>
<span v-if="isFlatTask" class="task-prompt-id">
{{ task.promptId.split('-')[0] }}
</span>
</Tag>
</div>
<div class="tag-wrapper">
<Button
v-if="task.isHistory && flatOutputs.length > 1"
outlined
@click="handleOutputLengthClick"
>
<span style="font-weight: 700">{{ flatOutputs.length }}</span>
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { api } from '@/scripts/api'
import { useLitegraphService } from '@/services/litegraphService'
import { TaskItemDisplayStatus } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
import ResultItem from './ResultItem.vue'
const props = defineProps<{
task: TaskItemImpl
isFlatTask: boolean
}>()
const litegraphService = useLitegraphService()
const flatOutputs = props.task.flatOutputs
const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? (props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult?.nodeId
) ?? null)
: null
const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{
(
e: 'contextmenu',
value: { task: TaskItemImpl; event: MouseEvent; node: ComfyNode | null }
): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
onMounted(() => {
api.addEventListener('b_preview', onProgressPreviewReceived)
})
onUnmounted(() => {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
api.removeEventListener('b_preview', onProgressPreviewReceived)
})
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e, node })
}
const handlePreview = () => {
emit('preview', props.task)
}
const handleOutputLengthClick = () => {
emit('task-output-length-clicked', props.task)
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warn'
}
}
const taskStatusText = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return '<i class="pi pi-spin pi-spinner" style="font-weight: bold"></i> Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
return 'Cancelled'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
const onProgressPreviewReceived = async ({ detail }: CustomEvent) => {
if (props.task.displayStatus === TaskItemDisplayStatus.Running) {
if (progressPreviewBlobUrl.value) {
URL.revokeObjectURL(progressPreviewBlobUrl.value)
}
progressPreviewBlobUrl.value = URL.createObjectURL(detail)
}
}
const cancelledWithResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length
)
})
const cancelledWithoutResults = computed(() => {
return (
props.task.displayStatus === TaskItemDisplayStatus.Cancelled &&
flatOutputs.length === 0
)
})
</script>
<style scoped>
.task-result-preview {
aspect-ratio: 1 / 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.task-result-preview i,
.task-result-preview span {
font-size: 2rem;
}
.task-item {
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.task-item-details {
position: absolute;
top: 0.5rem;
padding: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
z-index: 1;
pointer-events: none; /* Allow clicks to pass through this div */
}
/* Make individual controls clickable again by restoring pointer events */
.task-item-details .tag-wrapper,
.task-item-details button {
pointer-events: auto;
}
.task-node-link {
padding: 2px;
}
/* In dark mode, transparent background color for tags is not ideal for tags that
are floating on top of images. */
.tag-wrapper {
background-color: var(--p-primary-contrast-color);
border-radius: 6px;
display: inline-flex;
}
.node-name-tag {
word-break: break-all;
}
.status-tag-group {
display: flex;
flex-direction: column;
}
.progress-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
</style>

View File

@@ -63,7 +63,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

@@ -181,7 +181,6 @@ Composables for sidebar functionality:
|------------|-------------|
| `useModelLibrarySidebarTab` | Manages the model library sidebar tab |
| `useNodeLibrarySidebarTab` | Manages the node library sidebar tab |
| `useQueueSidebarTab` | Manages the queue sidebar tab |
| `useWorkflowsSidebarTab` | Manages the workflows sidebar tab |
### Tree

View File

@@ -79,10 +79,64 @@ export interface GraphNodeManager {
cleanup(): void
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}
}
export function isValidWidgetValue(value: unknown): value is WidgetValue {
return (
value === null ||
value === undefined ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'object'
)
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
const nodeDefStore = useNodeDefStore()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
@@ -148,45 +202,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null
})
})
return (
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}) ?? []
)
return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? []
})
const nodeType =

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { resampleSegment } from './splineUtils'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('Shift+Click Drawing Logic', () => {
it('should generate equidistant points across connected segments', () => {
const spacing = 4
let remainder = spacing // Simulate start point already painted
const outputPoints: Point[] = []
// Define points: A -> B -> C
// A(0,0) -> B(10,0) -> C(20,0)
// Total length 20. Spacing 4.
// Expected points at x = 4, 8, 12, 16, 20
const pA = { x: 0, y: 0 }
const pB = { x: 10, y: 0 }
const pC = { x: 20, y: 0 }
// Segment 1: A -> B
const result1 = resampleSegment([pA, pB], spacing, remainder)
outputPoints.push(...result1.points)
remainder = result1.remainder
// Verify intermediate state
// Length 10. Spacing 4. Start offset 4.
// Points at 4, 8. Next at 12.
// Remainder = 12 - 10 = 2.
expect(result1.points.length).toBe(2)
expect(result1.points[0].x).toBeCloseTo(4)
expect(result1.points[1].x).toBeCloseTo(8)
expect(remainder).toBeCloseTo(2)
// Segment 2: B -> C
const result2 = resampleSegment([pB, pC], spacing, remainder)
outputPoints.push(...result2.points)
remainder = result2.remainder
// Verify final state
// Start offset 2. Points at 2, 6, 10 (relative to B).
// Absolute x: 12, 16, 20.
expect(result2.points.length).toBe(3)
expect(result2.points[0].x).toBeCloseTo(12)
expect(result2.points[1].x).toBeCloseTo(16)
expect(result2.points[2].x).toBeCloseTo(20)
// Verify all distances
// Note: The first point is at distance `spacing` from start (0,0)
// Subsequent points are `spacing` apart.
let prevX = 0
for (const p of outputPoints) {
const dist = p.x - prevX
expect(dist).toBeCloseTo(spacing)
prevX = p.x
}
})
it('should handle segments shorter than spacing', () => {
const spacing = 10
let remainder = spacing // Simulate start point already painted
// A(0,0) -> B(5,0) -> C(15,0)
const pA = { x: 0, y: 0 }
const pB = { x: 5, y: 0 }
const pC = { x: 15, y: 0 }
// Segment 1: A -> B (Length 5)
// Spacing 10. No points should be generated.
// Remainder should be 5 (next point needs 5 more units).
const result1 = resampleSegment([pA, pB], spacing, remainder)
expect(result1.points.length).toBe(0)
expect(result1.remainder).toBeCloseTo(5)
remainder = result1.remainder
// Segment 2: B -> C (Length 10)
// Start offset 5. First point at 5 (relative to B).
// Absolute x = 10.
// Next point at 15 (relative to B). Segment ends at 10.
// Remainder = 15 - 10 = 5.
const result2 = resampleSegment([pB, pC], spacing, remainder)
expect(result2.points.length).toBe(1)
expect(result2.points[0].x).toBeCloseTo(10)
expect(result2.remainder).toBeCloseTo(5)
})
})

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { StrokeProcessor } from './StrokeProcessor'
import type { Point } from '@/extensions/core/maskeditor/types'
describe('StrokeProcessor', () => {
it('should generate equidistant points from irregular input', () => {
const spacing = 10
const processor = new StrokeProcessor(spacing)
const outputPoints: Point[] = []
// Simulate a horizontal line drawn with irregular speed
// Points: (0,0) -> (5,0) -> (25,0) -> (30,0) -> (100,0)
const inputPoints: Point[] = [
{ x: 0, y: 0 },
{ x: 5, y: 0 }, // dist 5
{ x: 25, y: 0 }, // dist 20
{ x: 30, y: 0 }, // dist 5
{ x: 100, y: 0 } // dist 70
]
for (const p of inputPoints) {
outputPoints.push(...processor.addPoint(p))
}
outputPoints.push(...processor.endStroke())
// Verify we have points
expect(outputPoints.length).toBeGreaterThan(0)
// Verify spacing
// Note: The first few points might be affected by the start condition,
// but the middle section should be perfectly spaced.
// Also, Catmull-Rom splines don't necessarily pass through control points in a straight line
// if the points are collinear, they should be straight.
// Let's check distances between consecutive points
const distances: number[] = []
for (let i = 1; i < outputPoints.length; i++) {
const dx = outputPoints[i].x - outputPoints[i - 1].x
const dy = outputPoints[i].y - outputPoints[i - 1].y
distances.push(Math.hypot(dx, dy))
}
// Check that distances are close to spacing
// We allow a small epsilon because of floating point and spline approximation
// Filter out the very last segment which might be shorter (remainder)
// But wait, our logic doesn't output the last point if it's not a full spacing step?
// resampleSegment outputs points at [start + spacing, start + 2*spacing, ...]
// It does NOT output the end point of the segment.
// So all distances between output points should be exactly `spacing`.
// EXCEPT possibly if the spline curvature makes the straight-line distance slightly different
// from the arc length. But for a straight line input, it should be exact.
// However, catmull-rom with collinear points IS a straight line.
// Let's log the distances for debugging if test fails
// console.log('Distances:', distances)
// All distances should be approximately equal to spacing
// We might have a gap between segments if the logic isn't perfect,
// but within a segment it's guaranteed by resampleSegment.
// The critical part is the transition between segments.
for (let i = 0; i < distances.length; i++) {
const d = distances[i]
if (Math.abs(d - spacing) > 0.5) {
console.log(
`Distance mismatch at index ${i}: ${d} (expected ${spacing})`
)
console.log(`Point ${i}:`, outputPoints[i])
console.log(`Point ${i + 1}:`, outputPoints[i + 1])
}
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a simple 3-point stroke', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 0, y: 0 }))
points.push(...processor.addPoint({ x: 10, y: 0 }))
points.push(...processor.addPoint({ x: 20, y: 0 }))
points.push(...processor.endStroke())
expect(points.length).toBeGreaterThan(0)
// Check distances
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const d = Math.hypot(dx, dy)
expect(d).toBeCloseTo(spacing, 1)
}
})
it('should handle a single point click', () => {
const spacing = 5
const processor = new StrokeProcessor(spacing)
const points: Point[] = []
points.push(...processor.addPoint({ x: 100, y: 100 }))
points.push(...processor.endStroke())
expect(points.length).toBe(1)
expect(points[0]).toEqual({ x: 100, y: 100 })
})
})

View File

@@ -0,0 +1,115 @@
import type { Point } from '@/extensions/core/maskeditor/types'
import { catmullRomSpline, resampleSegment } from './splineUtils'
export class StrokeProcessor {
private controlPoints: Point[] = []
private remainder: number = 0
private spacing: number
private isFirstPoint: boolean = true
private hasProcessedSegment: boolean = false
constructor(spacing: number) {
this.spacing = spacing
}
/**
* Adds a point to the stroke and returns any new equidistant points generated.
* Maintain a sliding window of 4 control points for spline generation
*/
public addPoint(point: Point): Point[] {
// Initialize buffer with the first point
if (this.isFirstPoint) {
this.controlPoints.push(point) // p0: phantom start point
this.controlPoints.push(point) // p1: actual start point
this.isFirstPoint = false
return [] // Wait for more points to form a segment
}
this.controlPoints.push(point)
// Require 4 points for a spline segment
if (this.controlPoints.length < 4) {
return []
}
// Generate segment p1->p2
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = this.controlPoints[3]
const newPoints = this.processSegment(p0, p1, p2, p3)
// Slide window
this.controlPoints.shift()
return newPoints
}
/**
* End stroke and flush remaining segments
*/
public endStroke(): Point[] {
if (this.controlPoints.length < 2) {
// Insufficient points for a segment
return []
}
// Process remaining segments by duplicating the last point
const newPoints: Point[] = []
// Flush the buffer by processing the final segment
while (this.controlPoints.length >= 3) {
const p0 = this.controlPoints[0]
const p1 = this.controlPoints[1]
const p2 = this.controlPoints[2]
const p3 = p2 // Duplicate last point as phantom end
const points = this.processSegment(p0, p1, p2, p3)
newPoints.push(...points)
this.controlPoints.shift()
}
// Handle single point click
if (!this.hasProcessedSegment && this.controlPoints.length >= 2) {
// Process zero-length segment for single point
const p = this.controlPoints[1]
const points = this.processSegment(p, p, p, p)
newPoints.push(...points)
}
return newPoints
}
private processSegment(p0: Point, p1: Point, p2: Point, p3: Point): Point[] {
this.hasProcessedSegment = true
// Generate dense points for the segment
const densePoints: Point[] = []
// Adaptive sampling based on segment length
const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y)
// Use 1 sample per pixel, but at least 5 samples to ensure smoothness for short segments
// and cap at a reasonable maximum if needed (though not strictly necessary with density)
const samples = Math.max(5, Math.ceil(dist))
for (let i = 0; i < samples; i++) {
const t = i / samples
densePoints.push(catmullRomSpline(p0, p1, p2, p3, t))
}
// Add segment end point
densePoints.push(p2)
// Resample points with carried-over remainder
const { points, remainder } = resampleSegment(
densePoints,
this.spacing,
this.remainder
)
this.remainder = remainder
return points
}
}

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
describe('brushUtils', () => {
describe('getEffectiveBrushSize', () => {
it('should return original size when hardness is 1.0', () => {
const size = 100
const hardness = 1.0
expect(getEffectiveBrushSize(size, hardness)).toBe(100)
})
it('should return 1.5x size when hardness is 0.0', () => {
const size = 100
const hardness = 0.0
expect(getEffectiveBrushSize(size, hardness)).toBe(150)
})
it('should interpolate linearly', () => {
const size = 100
const hardness = 0.5
// Scale should be 1.0 + 0.5 * 0.5 = 1.25
expect(getEffectiveBrushSize(size, hardness)).toBe(125)
})
})
describe('getEffectiveHardness', () => {
it('should return same hardness if effective size matches size', () => {
const size = 100
const hardness = 0.8
const effectiveSize = 100
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.8)
})
it('should scale hardness down as effective size increases', () => {
const size = 100
const hardness = 0.5
// Effective size at 0.5 hardness is 125
const effectiveSize = 125
// Hard core radius = 50. New hardness = 50 / 125 = 0.4
expect(getEffectiveHardness(size, hardness, effectiveSize)).toBe(0.4)
})
it('should return 0 if effective size is 0', () => {
expect(getEffectiveHardness(100, 0.5, 0)).toBe(0)
})
})
})

View File

@@ -0,0 +1,34 @@
/**
* Calculates the effective brush size based on the base size and hardness.
* As hardness decreases, the effective size increases to allow for a softer falloff.
*
* @param size - The base radius of the brush
* @param hardness - The hardness of the brush (0.0 to 1.0)
* @returns The effective radius of the brush
*/
export function getEffectiveBrushSize(size: number, hardness: number): number {
// Scale factor for maximum softness
const MAX_SCALE = 1.5
const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
return size * scale
}
/**
* Calculates the effective hardness to maintain the visual "hard core" of the brush.
* Since the effective size is larger, we need to adjust the hardness value so that
* the inner hard circle remains at the same physical radius as the original size * hardness.
*
* @param size - The base radius of the brush
* @param hardness - The base hardness of the brush
* @param effectiveSize - The effective radius (calculated by getEffectiveBrushSize)
* @returns The adjusted hardness value (0.0 to 1.0)
*/
export function getEffectiveHardness(
size: number,
hardness: number,
effectiveSize: number
): number {
if (effectiveSize <= 0) return 0
// Adjust hardness to maintain the physical radius of the hard core
return (size * hardness) / effectiveSize
}

View File

@@ -0,0 +1,805 @@
import * as d from 'typegpu/data'
import { StrokePoint } from './gpuSchema'
import {
brushFragment,
brushVertex,
blitShader,
compositeShader,
readbackShader
} from './brushShaders'
// ... (rest of the file)
const QUAD_VERTS = new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1])
const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3])
const UNIFORM_SIZE = 48 // Uniform buffer size aligned to 16 bytes
const STROKE_STRIDE = d.sizeOf(StrokePoint) // 16
const MAX_STROKES = 10000
export class GPUBrushRenderer {
private device: GPUDevice
// Buffers
private quadVertexBuffer: GPUBuffer
private indexBuffer: GPUBuffer
private instanceBuffer: GPUBuffer
private uniformBuffer: GPUBuffer
// Pipelines
private renderPipeline: GPURenderPipeline // Standard alpha blending pipeline
private accumulatePipeline: GPURenderPipeline // SourceOver blending pipeline for stroke accumulation
private blitPipeline: GPURenderPipeline
private compositePipeline: GPURenderPipeline // Composite pipeline that applies opacity
private compositePipelinePreview: GPURenderPipeline // Pipeline for rendering to the preview canvas
private erasePipeline: GPURenderPipeline // Pipeline for erasing (Destination Out)
private erasePipelinePreview: GPURenderPipeline // Eraser pipeline for the preview canvas
readbackPipeline: GPUComputePipeline // Compute pipeline for texture readback
// Bind Group Layouts
private uniformBindGroupLayout: GPUBindGroupLayout
private textureBindGroupLayout: GPUBindGroupLayout
// Shared Bind Groups
private mainUniformBindGroup: GPUBindGroup
// Textures
private currentStrokeTexture: GPUTexture | null = null
private currentStrokeView: GPUTextureView | null = null
// Cached Bind Groups
private compositeTextureBindGroup: GPUBindGroup | null = null
private previewTextureBindGroup: GPUBindGroup | null = null
// Removed separate uniform bind groups as we will use mainUniformBindGroup
private lastReadbackTexture: GPUTexture | null = null
private lastReadbackBuffer: GPUBuffer | null = null
private readbackBindGroup: GPUBindGroup | null = null
private lastBackgroundTexture: GPUTexture | null = null
private backgroundBindGroup: GPUBindGroup | null = null
constructor(
device: GPUDevice,
presentationFormat: GPUTextureFormat = 'rgba8unorm'
) {
this.device = device
// --- 1. Initialize Buffers ---
this.quadVertexBuffer = device.createBuffer({
size: QUAD_VERTS.byteLength,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true
})
new Float32Array(this.quadVertexBuffer.getMappedRange()).set(QUAD_VERTS)
this.quadVertexBuffer.unmap()
this.indexBuffer = device.createBuffer({
size: QUAD_INDICES.byteLength,
usage: GPUBufferUsage.INDEX,
mappedAtCreation: true
})
new Uint16Array(this.indexBuffer.getMappedRange()).set(QUAD_INDICES)
this.indexBuffer.unmap()
this.instanceBuffer = device.createBuffer({
size: MAX_STROKES * STROKE_STRIDE,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST
})
this.uniformBuffer = device.createBuffer({
size: UNIFORM_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
})
// --- 2. Brush Shader (Drawing) ---
const brushModuleV = device.createShaderModule({ code: brushVertex })
const brushModuleF = device.createShaderModule({ code: brushFragment })
// Create explicit bind group layouts
this.uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: 'uniform' }
}
]
})
this.textureBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: {} // default is float, 2d
}
]
})
this.mainUniformBindGroup = device.createBindGroup({
layout: this.uniformBindGroupLayout,
entries: [{ binding: 0, resource: { buffer: this.uniformBuffer } }]
})
const renderPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.uniformBindGroupLayout]
})
// Standard Render Pipeline (Alpha Blend)
this.renderPipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] // Quad vertex attributes
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' }, // Instance attributes: position
{ shaderLocation: 2, offset: 8, format: 'float32' }, // size
{ shaderLocation: 3, offset: 12, format: 'float32' } // pressure
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Accumulate strokes using SourceOver blending to ensure smooth intersections.
this.accumulatePipeline = device.createRenderPipeline({
layout: renderPipelineLayout,
vertex: {
module: brushModuleV,
entryPoint: 'vs',
buffers: [
{
arrayStride: 8,
stepMode: 'vertex',
attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }]
},
{
arrayStride: 16,
stepMode: 'instance',
attributes: [
{ shaderLocation: 1, offset: 0, format: 'float32x2' },
{ shaderLocation: 2, offset: 8, format: 'float32' },
{ shaderLocation: 3, offset: 12, format: 'float32' }
]
}
]
},
fragment: {
module: brushModuleF,
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
// Use SourceOver blending for smooth stroke intersections.
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 3. Blit Pipeline (For Preview) ---
const blitPipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [this.textureBindGroupLayout]
})
this.blitPipeline = device.createRenderPipeline({
layout: blitPipelineLayout,
vertex: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: blitShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat, // Use the presentation format
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 4. Composite Pipeline ---
const compositePipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [
this.textureBindGroupLayout,
this.uniformBindGroupLayout
]
})
// Standard composite pipeline for offscreen textures
this.compositePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Composite pipeline for the preview canvas
this.compositePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 5. Erase Pipeline (Destination Out) ---
// Standard erase pipeline for offscreen textures
this.erasePipeline = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: 'rgba8unorm',
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst * (1 - src_alpha)
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha', // dst_alpha * (1 - src_alpha)
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// Erase pipeline for the preview canvas
this.erasePipelinePreview = device.createRenderPipeline({
layout: compositePipelineLayout,
vertex: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'vs'
},
fragment: {
module: device.createShaderModule({ code: compositeShader }),
entryPoint: 'fs',
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'zero',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}
]
},
primitive: { topology: 'triangle-list' }
})
// --- 6. Readback Pipeline (Compute) ---
this.readbackPipeline = device.createComputePipeline({
layout: 'auto',
compute: {
module: device.createShaderModule({ code: readbackShader }),
entryPoint: 'main'
}
})
}
public prepareStroke(width: number, height: number) {
// Initialize or resize the accumulation texture
if (
!this.currentStrokeTexture ||
this.currentStrokeTexture.width !== width ||
this.currentStrokeTexture.height !== height
) {
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
this.currentStrokeTexture = this.device.createTexture({
size: [width, height],
format: 'rgba8unorm',
usage:
GPUTextureUsage.RENDER_ATTACHMENT |
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_SRC
})
this.currentStrokeView = this.currentStrokeTexture.createView()
// Invalidate texture-dependent bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
// Readback bind group might also be invalid if it was using the old texture
if (this.lastReadbackTexture === this.currentStrokeTexture) {
this.readbackBindGroup = null
this.lastReadbackTexture = null
}
}
// Clear the accumulation texture
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: this.currentStrokeView!,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public renderStrokeToAccumulator(
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (!this.currentStrokeView) return
// Render stroke using accumulation pipeline
this.renderStrokeInternal(
this.currentStrokeView,
this.accumulatePipeline,
points,
settings
)
}
public compositeStroke(
targetView: GPUTextureView,
settings: {
opacity: number
color: [number, number, number]
hardness: number // Required for uniform buffer layout
screenSize: [number, number]
brushShape: number
isErasing?: boolean
}
) {
if (!this.currentStrokeTexture) return
// Update uniforms for the composite pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape // Brush shape: 0=Circle, 1=Square
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
const encoder = this.device.createCommandEncoder()
// Choose pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipeline
: this.compositePipeline
// 1. Texture Bind Group (Group 0)
if (!this.compositeTextureBindGroup) {
this.compositeTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
// It is compatible because we used the same layout
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.compositeTextureBindGroup)
pass.setBindGroup(1, this.mainUniformBindGroup)
pass.draw(3)
pass.end()
this.device.queue.submit([encoder.finish()])
}
// Direct rendering method
public renderStroke(
targetView: GPUTextureView,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
this.renderStrokeInternal(targetView, this.renderPipeline, points, settings)
}
private renderStrokeInternal(
targetView: GPUTextureView,
pipeline: GPURenderPipeline,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (points.length === 0) return
// 1. Update Uniforms
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.width
f32[7] = settings.height
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// 2. Batch Rendering
let processedPoints = 0
while (processedPoints < points.length) {
const batchSize = Math.min(points.length - processedPoints, MAX_STROKES)
const iData = new Float32Array(batchSize * 4)
for (let i = 0; i < batchSize; i++) {
const p = points[processedPoints + i]
iData[i * 4 + 0] = p.x
iData[i * 4 + 1] = p.y
iData[i * 4 + 2] = settings.size
iData[i * 4 + 3] = p.pressure
}
this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
// 3. Render Pass
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, this.mainUniformBindGroup)
pass.setVertexBuffer(0, this.quadVertexBuffer)
pass.setVertexBuffer(1, this.instanceBuffer)
pass.setIndexBuffer(this.indexBuffer, 'uint16')
pass.drawIndexed(6, batchSize)
pass.end()
this.device.queue.submit([encoder.finish()])
processedPoints += batchSize
}
}
// Blit the accumulated stroke to the preview canvas
public blitToCanvas(
destinationCtx: GPUCanvasContext,
settings: {
opacity: number
color: [number, number, number]
hardness: number
screenSize: [number, number]
brushShape: number
isErasing?: boolean
},
backgroundTexture?: GPUTexture
) {
const encoder = this.device.createCommandEncoder()
const destView = destinationCtx.getCurrentTexture().createView()
if (backgroundTexture) {
// Draw background texture to allow erasing effect on existing content
if (
this.lastBackgroundTexture !== backgroundTexture ||
!this.backgroundBindGroup
) {
this.backgroundBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [{ binding: 0, resource: backgroundTexture.createView() }]
})
this.lastBackgroundTexture = backgroundTexture
}
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear', // Clear attachment before drawing
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.setPipeline(this.blitPipeline)
pass.setBindGroup(0, this.backgroundBindGroup)
pass.draw(3)
pass.end()
} else {
// Clear the destination texture
const clearPass = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
clearPass.end()
}
// Draw the accumulated stroke
if (this.currentStrokeTexture) {
// Update uniforms for the preview pass
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)
f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.screenSize[0]
f32[7] = settings.screenSize[1]
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)
// Select preview pipeline based on operation
const pipeline = settings.isErasing
? this.erasePipelinePreview
: this.compositePipelinePreview
// 1. Texture Bind Group (Group 0)
if (!this.previewTextureBindGroup) {
this.previewTextureBindGroup = this.device.createBindGroup({
layout: this.textureBindGroupLayout,
entries: [
{ binding: 0, resource: this.currentStrokeTexture.createView() }
]
})
}
// 2. Uniform Bind Group (Group 1) - Use shared mainUniformBindGroup
const passStroke = encoder.beginRenderPass({
colorAttachments: [
{
view: destView,
loadOp: 'load', // Load the previous pass result
storeOp: 'store'
}
]
})
passStroke.setPipeline(pipeline)
passStroke.setBindGroup(0, this.previewTextureBindGroup)
passStroke.setBindGroup(1, this.mainUniformBindGroup)
passStroke.draw(3)
passStroke.end()
}
this.device.queue.submit([encoder.finish()])
}
// Clear the preview canvas
public clearPreview(destinationCtx: GPUCanvasContext) {
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: destinationCtx.getCurrentTexture().createView(),
loadOp: 'clear',
clearValue: { r: 0, g: 0, b: 0, a: 0 },
storeOp: 'store'
}
]
})
pass.end()
this.device.queue.submit([encoder.finish()])
}
public prepareReadback(texture: GPUTexture, outputBuffer: GPUBuffer) {
if (
this.lastReadbackTexture !== texture ||
this.lastReadbackBuffer !== outputBuffer ||
!this.readbackBindGroup
) {
this.readbackBindGroup = this.device.createBindGroup({
layout: this.readbackPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: texture.createView() },
{ binding: 1, resource: { buffer: outputBuffer } }
]
})
this.lastReadbackTexture = texture
this.lastReadbackBuffer = outputBuffer
}
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginComputePass()
pass.setPipeline(this.readbackPipeline)
pass.setBindGroup(0, this.readbackBindGroup)
const width = texture.width
const height = texture.height
// Dispatch workgroups based on texture dimensions (8x8 block size)
pass.dispatchWorkgroups(Math.ceil(width / 8), Math.ceil(height / 8))
pass.end()
this.device.queue.submit([encoder.finish()])
}
public destroy() {
this.quadVertexBuffer.destroy()
this.indexBuffer.destroy()
this.instanceBuffer.destroy()
this.uniformBuffer.destroy()
if (this.currentStrokeTexture) this.currentStrokeTexture.destroy()
// Clear cached bind groups
this.compositeTextureBindGroup = null
this.previewTextureBindGroup = null
this.readbackBindGroup = null
this.backgroundBindGroup = null
this.lastReadbackTexture = null
this.lastReadbackBuffer = null
this.lastBackgroundTexture = null
}
}

View File

@@ -0,0 +1,171 @@
import tgpu from 'typegpu'
import * as d from 'typegpu/data'
import { BrushUniforms } from './gpuSchema'
const VertexOutput = d.struct({
position: d.builtin.position,
localUV: d.location(0, d.vec2f),
color: d.location(1, d.vec3f),
opacity: d.location(2, d.f32),
hardness: d.location(3, d.f32)
})
const brushVertexTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@vertex
fn vs(
@location(0) quadPos: vec2<f32>,
@location(1) pos: vec2<f32>,
@location(2) size: f32,
@location(3) pressure: f32
) -> VertexOutput {
// Convert diameter to radius
let radius = size * pressure;
let pixelPos = pos + (quadPos * radius);
// Convert pixel coordinates to Normalized Device Coordinates (NDC)
let ndcX = (pixelPos.x / globals.screenSize.x) * 2.0 - 1.0;
let ndcY = 1.0 - ((pixelPos.y / globals.screenSize.y) * 2.0); // Flip Y axis for WebGPU coordinate system
return VertexOutput(
vec4<f32>(ndcX, ndcY, 0.0, 1.0),
quadPos,
globals.brushColor,
pressure * globals.brushOpacity,
globals.hardness
);
}
`
export const brushVertex = tgpu.resolve({
template: brushVertexTemplate,
externals: {
BrushUniforms,
VertexOutput
}
})
const brushFragmentTemplate = `
@group(0) @binding(0) var<uniform> globals: BrushUniforms;
@fragment
fn fs(v: VertexOutput) -> @location(0) vec4<f32> {
var dist: f32;
if (globals.brushShape == 1u) {
// Calculate Chebyshev distance for square shape
dist = max(abs(v.localUV.x), abs(v.localUV.y));
} else {
// Calculate Euclidean distance for circle shape
dist = length(v.localUV);
}
if (dist > 1.0) { discard; }
// Calculate alpha with hardness and anti-aliasing
let edgeWidth = fwidth(dist);
let startFade = min(v.hardness, 1.0 - edgeWidth * 2.0);
let linearAlpha = 1.0 - smoothstep(startFade, 1.0, dist);
// Apply quadratic falloff for smoother edges
let alphaShape = pow(linearAlpha, 2.0);
// Return premultiplied alpha color
let alpha = alphaShape * v.opacity;
return vec4<f32>(v.color * alpha, alpha);
}
`
export const brushFragment = tgpu.resolve({
template: brushFragmentTemplate,
externals: {
VertexOutput,
BrushUniforms
}
})
const blitShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Treat texture as premultiplied to prevent double-darkening on overlaps
return c;
}
`
export const blitShader = tgpu.resolve({
template: blitShaderTemplate,
externals: {}
})
const compositeShaderTemplate = `
@vertex fn vs(@builtin(vertex_index) vIdx: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0)
);
return vec4<f32>(pos[vIdx], 0.0, 1.0);
}
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(1) @binding(0) var<uniform> globals: BrushUniforms;
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let sampled = textureLoad(myTexture, vec2<i32>(pos.xy), 0);
// Apply global brush opacity to accumulated coverage
return sampled * globals.brushOpacity;
}
`
export const compositeShader = tgpu.resolve({
template: compositeShaderTemplate,
externals: {
BrushUniforms
}
})
const readbackShaderTemplate = `
@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> outputBuf: array<u32>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let dims = textureDimensions(inputTex);
if (id.x >= dims.x || id.y >= dims.y) { return; }
let color = textureLoad(inputTex, vec2<i32>(id.xy), 0);
var r = color.r;
var g = color.g;
var b = color.b;
let a = color.a;
if (a > 0.0) {
r = r / a;
g = g / a;
b = b / a;
}
let ir = u32(clamp(r * 255.0, 0.0, 255.0));
let ig = u32(clamp(g * 255.0, 0.0, 255.0));
let ib = u32(clamp(b * 255.0, 0.0, 255.0));
let ia = u32(clamp(a * 255.0, 0.0, 255.0));
// Pack RGBA channels into a single u32 (Little Endian)
let packed = ir | (ig << 8u) | (ib << 16u) | (ia << 24u);
let index = id.y * dims.x + id.x;
outputBuf[index] = packed;
}
`
export const readbackShader = tgpu.resolve({
template: readbackShaderTemplate,
externals: {}
})

View File

@@ -0,0 +1,17 @@
import * as d from 'typegpu/data'
// Global brush uniforms
export const BrushUniforms = d.struct({
brushColor: d.vec3f,
brushOpacity: d.f32,
hardness: d.f32,
screenSize: d.vec2f,
brushShape: d.u32 // 0: Circle, 1: Square
})
// Per-point instance data
export const StrokePoint = d.struct({
pos: d.location(0, d.vec2f), // Center position
size: d.location(1, d.f32), // Brush radius
pressure: d.location(2, d.f32) // Pressure value (0.0 - 1.0)
})

View File

@@ -0,0 +1,126 @@
import type { Point } from '@/extensions/core/maskeditor/types'
/**
* Evaluates a Catmull-Rom spline at parameter t between p1 and p2
* @param p0 Previous control point
* @param p1 Start point of the curve segment
* @param p2 End point of the curve segment
* @param p3 Next control point
* @param t Parameter in range [0, 1]
* @returns Interpolated point on the curve
*/
export function catmullRomSpline(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number
): Point {
// Centripetal Catmull-Rom Spline (alpha = 0.5) to prevent loops and overshoots
const alpha = 0.5
const getT = (t: number, p0: Point, p1: Point) => {
const d = Math.hypot(p1.x - p0.x, p1.y - p0.y)
return t + Math.pow(d, alpha)
}
const t0 = 0
const t1 = getT(t0, p0, p1)
const t2 = getT(t1, p1, p2)
const t3 = getT(t2, p2, p3)
// Map normalized t to parameter range
const tInterp = t1 + (t2 - t1) * t
// Safe interpolation for coincident points
const interp = (
pA: Point,
pB: Point,
tA: number,
tB: number,
t: number
): Point => {
if (Math.abs(tB - tA) < 0.0001) return pA
const k = (t - tA) / (tB - tA)
return add(mul(pA, 1 - k), mul(pB, k))
}
// Barry-Goldman pyramidal interpolation
const A1 = interp(p0, p1, t0, t1, tInterp)
const A2 = interp(p1, p2, t1, t2, tInterp)
const A3 = interp(p2, p3, t2, t3, tInterp)
const B1 = interp(A1, A2, t0, t2, tInterp)
const B2 = interp(A2, A3, t1, t3, tInterp)
const C = interp(B1, B2, t1, t2, tInterp)
return C
}
function add(p1: Point, p2: Point): Point {
return { x: p1.x + p2.x, y: p1.y + p2.y }
}
function mul(p: Point, s: number): Point {
return { x: p.x * s, y: p.y * s }
}
/**
* Resamples a curve segment with a starting offset (remainder from previous segment).
* Returns the resampled points and the new remainder distance.
*
* @param points Points defining the curve segment
* @param spacing Desired spacing between points
* @param startOffset Distance to travel before placing the first point (remainder)
* @returns Object containing points and new remainder
*/
export function resampleSegment(
points: Point[],
spacing: number,
startOffset: number
): { points: Point[]; remainder: number } {
if (points.length === 0) return { points: [], remainder: startOffset }
const result: Point[] = []
let currentDist = 0
let nextSampleDist = startOffset
// Iterate through segment points
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i]
const p2 = points[i + 1]
const dx = p2.x - p1.x
const dy = p2.y - p1.y
const segmentLen = Math.hypot(dx, dy)
// Handle zero-length segments
if (segmentLen < 0.0001) {
while (nextSampleDist <= currentDist) {
result.push(p1)
nextSampleDist += spacing
}
continue
}
// Generate samples within the segment
while (nextSampleDist <= currentDist + segmentLen) {
const t = (nextSampleDist - currentDist) / segmentLen
// Interpolate
const x = p1.x + t * dx
const y = p1.y + t * dy
result.push({ x, y })
nextSampleDist += spacing
}
currentDist += segmentLen
}
// Calculate remainder distance for the next segment
const remainder = nextSampleDist - currentDist
return { points: result, remainder }
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,9 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
const states = ref<
{ mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[]
>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
@@ -53,7 +55,10 @@ export function useCanvasHistory(maxStates = 20) {
initialized.value = true
}
const saveState = () => {
const saveState = (
providedMaskData?: ImageData | ImageBitmap,
providedRgbData?: ImageData | ImageBitmap
) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
@@ -68,23 +73,32 @@ export function useCanvasHistory(maxStates = 20) {
states.value = states.value.slice(0, currentStateIndex.value + 1)
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
let maskState: ImageData | ImageBitmap
let rgbState: ImageData | ImageBitmap
if (providedMaskData && providedRgbData) {
maskState = providedMaskData
rgbState = providedRgbData
} else {
maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height)
}
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
if (states.value.length > maxStates) {
states.value.shift()
const removed = states.value.shift()
// Cleanup ImageBitmaps to avoid memory leaks
if (removed) {
if (removed.mask instanceof ImageBitmap) removed.mask.close()
if (removed.rgb instanceof ImageBitmap) removed.rgb.close()
}
currentStateIndex.value--
}
}
@@ -109,16 +123,35 @@ export function useCanvasHistory(maxStates = 20) {
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
const restoreState = (state: {
mask: ImageData | ImageBitmap
rgb: ImageData | ImageBitmap
}) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
maskCtx.putImageData(state.mask, 0, 0)
rgbCtx.putImageData(state.rgb, 0, 0)
if (state.mask instanceof ImageBitmap) {
maskCtx.clearRect(0, 0, state.mask.width, state.mask.height)
maskCtx.drawImage(state.mask, 0, 0)
} else {
maskCtx.putImageData(state.mask, 0, 0)
}
if (state.rgb instanceof ImageBitmap) {
rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height)
rgbCtx.drawImage(state.rgb, 0, 0)
} else {
rgbCtx.putImageData(state.rgb, 0, 0)
}
}
const clearStates = () => {
// Cleanup bitmaps
states.value.forEach((state) => {
if (state.mask instanceof ImageBitmap) state.mask.close()
if (state.rgb instanceof ImageBitmap) state.rgb.close()
})
states.value = []
currentStateIndex.value = -1
initialized.value = false
@@ -127,6 +160,7 @@ export function useCanvasHistory(maxStates = 20) {
return {
canUndo,
canRedo,
currentStateIndex,
saveInitialState,
saveState,
undo,

View File

@@ -80,21 +80,64 @@ export function useMaskEditorLoader() {
try {
validateNode(node)
const nodeImageUrl = getNodeImageUrl(node)
let nodeImageUrl = getNodeImageUrl(node)
const nodeImageRef = parseImageRef(nodeImageUrl)
let nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
if (imageWidget) {
if (typeof imageWidget.value === 'string') {
widgetFilename = imageWidget.value
} else if (
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
}
// If we have a widget filename, we should prioritize it over the node image
// because the node image might be stale (e.g. from a previous save)
// while the widget value reflects the current selection.
if (widgetFilename) {
try {
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
let filename = widgetFilename
let subfolder: string | undefined = undefined
let type: string | undefined = 'input' // Default to input for widget values
// Check for type in brackets at the end
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
if (typeMatch) {
type = typeMatch[1]
filename = filename.substring(
0,
filename.length - typeMatch[0].length
)
}
// Check for subfolder (forward slash separator)
const lastSlashIndex = filename.lastIndexOf('/')
if (lastSlashIndex !== -1) {
subfolder = filename.substring(0, lastSlashIndex)
filename = filename.substring(lastSlashIndex + 1)
}
nodeImageRef = {
filename,
type,
subfolder
}
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
} catch (e) {
console.warn('Failed to parse widget filename as ref', e)
}
}

View File

@@ -1,22 +0,0 @@
import { markRaw } from 'vue'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useQueueSidebarTab = (): SidebarTabExtension => {
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'queue',
icon: 'pi pi-history',
iconBadge: () => {
const value = queuePendingTaskCountStore.count.toString()
return value === '0' ? null : value
},
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
label: 'sideToolbar.labels.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}
}

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
@@ -1219,6 +1219,12 @@ export function useCoreCommands(): ComfyCommand[] {
await settingStore.set('Comfy.Assets.UseAssetAPI', !current)
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
}
},
{
id: 'Comfy.ToggleLinear',
icon: 'pi pi-database',
label: 'toggle linear mode',
function: () => (canvasStore.linearMode = !canvasStore.linearMode)
}
]

View File

@@ -30,12 +30,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.RefreshNodeDefinitions'
},
{
combo: {
key: 'q'
},
commandId: 'Workspace.ToggleSidebarTab.queue'
},
{
combo: {
key: 'w'

View File

@@ -1,3 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
export const CORE_MENU_COMMANDS = [
[[], ['Comfy.NewBlankWorkflow']],
[[], []], // Separator after New
@@ -14,13 +16,16 @@ export const CORE_MENU_COMMANDS = [
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[
['Edit'],
[
'Comfy.RefreshNodeDefinitions',
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
...(isCloud
? []
: [
'Comfy.Memory.UnloadModels',
'Comfy.Memory.UnloadModelsAndExecutionCache'
])
]
],
[['View'], []],

View File

@@ -0,0 +1,114 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import { zDynamicComboInputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
function dynamicComboWidget(
node: LGraphNode,
inputName: string,
untypedInputData: InputSpec,
appArg: ComfyApp,
widgetName?: string
) {
const { addNodeInput } = useLitegraphService()
const parseResult = zDynamicComboInputSpec.safeParse(untypedInputData)
if (!parseResult.success) throw new Error('invalid DynamicCombo spec')
const inputData = parseResult.data
const options = Object.fromEntries(
inputData[1].options.map(({ key, inputs }) => [key, inputs])
)
const subSpec: ComboInputSpec = [Object.keys(options), {}]
const { widget, minWidth, minHeight } = app.widgets['COMBO'](
node,
inputName,
subSpec,
appArg,
widgetName
)
let currentDynamicNames: string[] = []
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
//TODO: Calculate intersection for widgets that persist across options
//This would potentially allow links to be retained
for (const name of currentDynamicNames) {
const inputIndex = node.inputs.findIndex((input) => input.name === name)
if (inputIndex !== -1) node.removeInput(inputIndex)
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
]
for (const [inputType, isOptional] of inputTypes)
for (const name in inputType ?? {}) {
addNodeInput(
node,
transformInputSpecV1ToV2(inputType![name], {
name,
isOptional
})
)
currentDynamicNames.push(name)
}
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
node.inputs.length !== startingInputLength
)
//input is inputOnly, but lacks an insertion point
throw new Error('Failed to find input socket for ' + widget.name)
return
}
const addedInputs = node
.spliceInputs(startingInputLength)
.map((addedInput) => {
const existingInput = node.inputs.findIndex(
(existingInput) => addedInput.name === existingInput.name
)
return existingInput === -1
? addedInput
: node.spliceInputs(existingInput, 1)[0]
})
//assume existing inputs are in correct order
node.spliceInputs(inputInsertionPoint, 0, ...addedInputs)
node.size[1] = node.computeSize([...node.size])[1]
}
//A little hacky, but onConfigure won't work.
//It fires too late and is overly disruptive
let widgetValue = widget.value
Object.defineProperty(widget, 'value', {
get() {
return widgetValue
},
set(value) {
widgetValue = value
updateWidgets(value)
}
})
widget.value = widgetValue
return { widget, minWidth, minHeight }
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }

View File

@@ -61,5 +61,5 @@ export interface Brush {
size: number
opacity: number
hardness: number
smoothingPrecision: number
stepSize: number
}

View File

@@ -835,6 +835,9 @@ export class LGraphNode
for (const w of this.widgets) {
if (!w) continue
const input = this.inputs.find((i) => i.widget?.name === w.name)
if (input?.label) w.label = input.label
if (
w.options?.property &&
this.properties[w.options.property] != undefined
@@ -845,15 +848,13 @@ export class LGraphNode
}
if (info.widgets_values) {
const widgetsWithValue = this.widgets.filter(
(w) => w.serialize !== false
const widgetsWithValue = this.widgets
.values()
.filter((w) => w.serialize !== false)
.filter((_w, idx) => idx < info.widgets_values!.length)
widgetsWithValue.forEach(
(widget, i) => (widget.value = info.widgets_values![i])
)
for (let i = 0; i < info.widgets_values.length; ++i) {
const widget = widgetsWithValue[i]
if (widget) {
widget.value = info.widgets_values[i]
}
}
}
}
@@ -881,7 +882,7 @@ export class LGraphNode
// special case for when there were errors
if (this.constructor === LGraphNode && this.last_serialization)
return this.last_serialization
return { ...this.last_serialization, mode: o.mode, pos: o.pos }
if (this.inputs)
o.inputs = this.inputs.map((input) => inputAsSerialisable(input))
@@ -1649,6 +1650,19 @@ export class LGraphNode
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
spliceInputs(
startIndex: number,
deleteCount = -1,
...toAdd: INodeInputSlot[]
): INodeInputSlot[] {
if (deleteCount < 0) return this.inputs.splice(startIndex)
const ret = this.inputs.splice(startIndex, deleteCount, ...toAdd)
this.inputs.slice(startIndex).forEach((input, index) => {
const link = input.link && this.graph?.links?.get(input.link)
if (link) link.target_slot = startIndex + index
})
return ret
}
/**
* computes the minimum size of a node according to its inputs and output slots

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

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "التحقق من التحديثات"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "فتح مجلد العقد المخصصة"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "فتح مجلد المدخلات"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "فتح مجلد السجلات"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "فتح extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "فتح مجلد النماذج"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "فتح مجلد المخرجات"
},
"Comfy-Desktop_OpenDevTools": {
"label": "فتح أدوات المطور"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "دليل المستخدم لسطح المكتب"
},
"Comfy-Desktop_Quit": {
"label": "خروج"
},
"Comfy-Desktop_Reinstall": {
"label": "إعادة التثبيت"
},
"Comfy-Desktop_Restart": {
"label": "إعادة التشغيل"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة"
},
"Comfy_BrowseModelAssets": {
"label": "تجريبي: تصفح أصول النماذج"
},
"Comfy_BrowseTemplates": {
"label": "تصفح القوالب"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "تحويل التحديد إلى رسم فرعي"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "تحرير عناصر واجهة الرسم البياني الفرعي"
},
"Comfy_Graph_ExitSubgraph": {
"label": "الخروج من الرسم البياني الفرعي"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "تجميع العقد المحددة"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "تبديل ترقية عنصر الواجهة المحوم فوقه"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "فك التفرع الفرعي المحدد"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "عرض نافذة الإعدادات"
},
"Comfy_ToggleAssetAPI": {
"label": "تجريبي: تمكين AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "أداء اللوحة"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "تسجيل الخروج"
},
"Experimental_ToggleVueNodes": {
"label": "تجريبي: تمكين عقد Vue"
},
"Workspace_CloseWorkflow": {
"label": "إغلاق سير العمل الحالي"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "تبديل وضع التركيز"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تبديل الشريط الجانبي للأصول",
"tooltip": "الأصول"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "تبديل الشريط الجانبي لمكتبة النماذج",
"tooltip": "مكتبة النماذج"
@@ -298,31 +281,8 @@
"label": "تبديل الشريط الجانبي لمكتبة العقد",
"tooltip": "مكتبة العقد"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "تبديل الشريط الجانبي لقائمة الانتظار",
"tooltip": "قائمة الانتظار"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "تبديل الشريط الجانبي لسير العمل",
"tooltip": "سير العمل"
},
"Comfy_BrowseModelAssets": {
"label": "تجريبي: تصفح أصول النماذج"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "تحرير عناصر واجهة الرسم البياني الفرعي"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "تبديل ترقية عنصر الواجهة المحوم فوقه"
},
"Comfy_ToggleAssetAPI": {
"label": "تجريبي: تمكين AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "تجريبي: تمكين عقد Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "تبديل الشريط الجانبي للأصول",
"tooltip": "الأصول"
}
}

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