Compare commits

...

49 Commits

Author SHA1 Message Date
bymyself
85783a9932 chore: Remove unrelated files from pre-init lifecycle hook branch 2025-08-17 14:26:51 -07:00
bymyself
501a7e49e5 feat: Add preInit lifecycle hook for early extension initialization
- Add preInit hook to ComfyExtension interface that runs before canvas creation
- Implement preInit invocation in app.ts after extension loading
- Create example extension demonstrating header provider registration
- Add comprehensive tests for hook execution order and error handling

This enables extensions to register services and cross-cutting concerns
before any other initialization occurs.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 14:25:21 -07:00
bymyself
4ed516c3a8 [feat] Add header registry infrastructure
Create extensible header registration system for HTTP requests:
- Add HeaderRegistry service with VSCode-style registration patterns
- Create network client adapters for axios and fetch with automatic header injection
- Add comprehensive TypeScript types for header providers
- Include example implementations for extension developers

This provides the foundation for extensions to register custom headers that will be automatically added to all network requests.

Part of #5017
2025-08-17 14:25:21 -07:00
bymyself
efa440a381 chore: Remove unrelated files from integrate header registry branch 2025-08-17 14:16:53 -07:00
bymyself
d05153a0dc [feat] Integrate header registry with all HTTP clients
- Replace direct fetch() with fetchWithHeaders across entire codebase
- Replace axios.create() with createAxiosWithHeaders in all services
- Update tests to properly mock network client adapters
- Fix hoisting issues in test mocks for axios instances
- Ensure all network calls now support header injection

This completes Step 2: integrating the header registry infrastructure with all existing HTTP clients in the codebase.
2025-08-17 14:00:48 -07:00
bymyself
d6695ea66e [feat] Add header registry infrastructure
Create extensible header registration system for HTTP requests:
- Add HeaderRegistry service with VSCode-style registration patterns
- Create network client adapters for axios and fetch with automatic header injection
- Add comprehensive TypeScript types for header providers
- Include example implementations for extension developers

This provides the foundation for extensions to register custom headers that will be automatically added to all network requests.

Part of #5017
2025-08-17 14:00:48 -07:00
bymyself
0d6e5a75a9 feat: Add header registry infrastructure
- Create TypeScript interfaces for header providers
- Implement header registry with priority-based ordering
- Add network client adapters that integrate with the registry
- Add comprehensive unit tests

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-17 13:43:58 -07:00
bymyself
2e80a5789c [test] Fix axios mocking in firebaseAuthStore tests
Use vi.mock() with factory function to ensure axios mock is set up before module imports. This fixes test failures caused by the store creating axios client at module level.

Fixes test execution in CI
2025-08-15 15:37:06 -07:00
bymyself
81ddfc2f58 [fix] Update firebaseAuthStore tests to mock axios client
- Move axios mock setup before store import to ensure client is mocked
- Set up default successful responses for customer API endpoints
- Fix TypeScript issues with isAxiosError mock type
2025-08-15 14:40:29 -07:00
bymyself
e19f0b2da9 [fix] Add missing await for response.json() calls in template workflows 2025-08-15 14:14:03 -07:00
bymyself
afecff6c94 [feat] Complete network call consolidation to use consistent clients
Template Workflows:
- Replace direct fetch with api.fetchApi() for API endpoints
- Keep direct fetch for static file URLs (already using api.fileURL())

Model Exporter:
- Add logic to distinguish ComfyUI URLs from external URLs
- Use api.apiURL() for ComfyUI URLs, direct fetch for external URLs
- Maintain existing download functionality

Other files already following correct patterns:
- Upload Audio: Already using api.fetchApi()
- 3D Loading Utils: Already using api.fetchApi() (fetch call is for blob conversion)
- Download Utility: Uses direct fetch for external URLs (correct)

All network calls now use consistent client patterns where appropriate.
2025-08-15 13:34:37 -07:00
bymyself
5afeee258f [feat] Consolidate firebaseAuthStore network calls to use axios client
- Replace all direct fetch() calls with axios client instance
- Maintain exact same error handling behavior and logic flow
- Use consistent pattern with other services (customerEventsService)
- All customer API endpoints now use centralized client
- Prepares for header registration system implementation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 12:28:52 -07:00
ComfyUI Wiki
7aaa0f022e fix: Correct traditional Chinese to simplified Chinese in translations (#5005)
* Correct some translations that use traditional Chinese to simplified Chinese.

* Update locales [skip ci]

* Correct the rest of the translations

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-15 10:34:48 -07:00
Yoland Yan
a132dad216 [test] Add tests for --disable-api-nodes release fetch skip functionality (#4799)
- Add comprehensive test coverage for the new --disable-api-nodes argument handling
- Tests verify release fetching is properly skipped when argument is present
- Cover edge cases including multiple args, null argv, and missing system stats
- Ensures backward compatibility when argument is not present
2025-08-15 10:14:41 -07:00
AustinMroz
9dbdc6a72b Fix inconsistency on bypass from context menu (#4988)
When a node is bypassed from the selection toolbox or by pressing a
keybind for bypass, it will also recursively bypass the contents of a
subgraph. This effect was not applied when clicking the bypass button
from the context menu. The context menu option has been updated to
perform the same action as the others so that behaviour is consistent.
2025-08-14 22:34:20 -07:00
Comfy Org PR Bot
7b228d693d [release] Increment version to 1.26.3 (#4995)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-08-14 21:17:23 -07:00
Christian Byrne
547af0e043 feat: Add GTM feature summary step to release command (#4990)
- Adds Step 16 to analyze PRs for marketing-worthy features
- Extracts PR data including media assets (images, videos, GIFs)
- Claude evaluates which features would interest end users
- Generates gtm-summary-VERSION.md for sharing with marketing team
- Many releases will correctly identify no marketing features (normal for bug fixes)

This helps the GTM team identify demo opportunities without manual PR review.
2025-08-14 18:26:20 -07:00
pythongosssss
4ca6220adf Refactor app menu items (#4665)
* Restructures the application menu
- rename Workflow to File
- move new & template items to top level
- add View menu and related sub items

Commands
- add "active" state getter shown as checkmark in the menu

Node side panel
- add refresh node defs
- change reset view icon

Help center
- change to use store for visibility

Fixes
- Fix bug with mouse down where if you drag mouse out, mouse up wasn't caught
- Fix issue with canvas info setting not triggering a redraw on change

* Fix missing translation warnings

* Add separator under new

* tidy

* Update locales [skip ci]

* fix some tests

* fix

* Hide icon if there is an active state within the menu item group

* Update locales [skip ci]

* Fix tests

* Implement feedback
- Remove queue, node lib, model lib, workflows, manager, help center
- Add minimap, link visibility

* Update locales [skip ci]

* Add plus icon on "New" menu item

* Update locales [skip ci]

* Fix test

* Fix translations

* Update locales [skip ci]

* Update locales [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-14 14:53:46 -07:00
Christian Byrne
1e41c6dc45 fix: Handle missing subgraph inputs gracefully during workflow import (#4985)
When loading workflows, SubgraphNode would throw an error if an input
exists in the serialized data that doesn't exist in the current subgraph
definition. This can happen when:
- Subgraph definitions change after workflows are saved
- Workflows are shared between users with different subgraph versions
- Dynamic inputs were added that don't exist in the base definition

This change converts the hard error to a warning and continues processing,
allowing workflows to load even with mismatched subgraph configurations.

Fixes #4905
2025-08-14 14:32:01 -07:00
Christian Byrne
5224c63bce [fix] Prevent incompatible connections to SubgraphInputNode occupied slots (#4984)
## Summary

This PR fixes #4681 by building upon the foundation laid in PR #1182
(litegraph.js). It prevents incompatible type connections when dragging
from a normal node's output to a SubgraphInputNode's occupied slot.

Before:


https://github.com/user-attachments/assets/03def938-dccc-4b2c-b65b-745abf02a13b

After:


https://github.com/user-attachments/assets/7a0a2ed4-9ecd-4147-be56-d643d448d4cb

## Background

PR #1182 implemented:
- `isValidTarget()` method in SubgraphInput/SubgraphOutput classes for
validation
- Visual feedback during drag (40% opacity for invalid targets)
- Validation at the slot level

However, there was a missing piece: while the visual feedback correctly
showed invalid targets, the actual connection would still be made when
dropped.

## Changes

This PR extends PR #1182 by adding the missing connection prevention:

1. **Added `canConnectToSubgraphInput()` method** to render link
classes:
   - `MovingOutputLink`
   - `ToOutputRenderLink`
   - `FloatingRenderLink`
- All methods use the existing `SubgraphInput.isValidTarget()` from PR
#1182

2. **Added validation in `LinkConnector.dropOnIoNode()`**:
   - Checks `canConnectToSubgraphInput()` before allowing the connection
   - Logs a warning when rejecting invalid connections
   - Follows the same pattern as regular node connections

3. **Added `isSubgraphInputValidDrop()` method**:
   - Provides validation for hover states
   - Ensures consistent validation across the UI
2025-08-14 12:51:43 -07:00
Alexander Piskun
89c78b0ecb fix pricing for KlingImage2VideoNode (#4957)
## Summary

Following up #4938 where I forgot to add pricing for new model in the
`KlingImage2VideoNode`.

## Screenshots (if applicable)

<img width="1461" height="1228" alt="Screenshot from 2025-08-13
09-15-21"
src="https://github.com/user-attachments/assets/01be8ab9-820b-4112-9a54-1ce4f23de4eb"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4957-fix-pricing-for-KlingImage2VideoNode-24e6d73d36508122b40ede36fdd50115)
by [Unito](https://www.unito.io)
2025-08-13 17:33:35 -07:00
Christian Byrne
4a3bd39650 [feat] Restore group node conversion menu with deprecated label (#4967)
## Summary
- Partially reverts commit c84218d6 to restore group node functionality
in context menus
- Adds "(Deprecated)" label to indicate the feature is deprecated
- Fixes TypeError when right-clicking on group nodes
- Re-enables tests that were disabled when the feature was removed

## Changes
1. **Restored context menu options** - Added back "Convert to Group Node
(Deprecated)" and "Manage Group Nodes" menu items
2. **Fixed null reference error** - Added null-safe operator to prevent
errors when right-clicking group nodes
3. **Re-enabled tests** - Restored 7 tests that were disabled in commit
586f8824

## Test plan
- [x] Right-click on canvas → verify "Convert to Group Node
(Deprecated)" appears
- [x] Right-click on nodes → verify the same menu option appears
- [x] Select multiple nodes and use the menu option → verify conversion
works
- [x] Right-click on group nodes → verify no errors occur
- [x] Run browser tests → verify all re-enabled tests pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4967-feat-Restore-group-node-conversion-menu-with-deprecated-label-24e6d73d36508149a6f2dbef47223e94)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 15:13:18 -07:00
Christian Byrne
db1b81b7ff fix: Add guards for _listenerController.abort() calls in SubgraphNode (#4968)
This fix adds guards before calling `_listenerController.abort()` to
prevent runtime errors when loading workflows. The guards check that
`_listenerController` exists and has an `abort` function before calling
it, matching the pattern used in Comfy-Org/litegraph.js#1134.

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4968-fix-Add-guards-for-_listenerController-abort-calls-in-SubgraphNode-24e6d73d3650813ebeeed69ee676faeb)
by [Unito](https://www.unito.io)
2025-08-13 14:42:34 -07:00
Terry Jia
5e81343142 show group self color in minimap (#4954)
a tiny fix that show group self color in minimap when checking node
color

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4954-show-group-self-color-in-minimap-24e6d73d3650812dbc58e9b134805f2d)
by [Unito](https://www.unito.io)
2025-08-13 14:39:08 -07:00
AustinMroz
6566acb406 Bundled subgraph fixes (#4964)
### Group support for subgraph unpacking
The unpacking code would silently delete groups (the cosmetic colored
rectangles). They are now correctly transferred.
### Fix subgraph node position on conversion to subgraph
Converting to subgraph will no longer cause nodes to inch upwards

![subgraph-conversion-positioning](https://github.com/user-attachments/assets/e120c3f9-5602-4dba-9075-c1eadb534f9a)
### Make unpacking use same positioning calcs as conversion
Non trivial, but unpacking is now a proper inverse for conversion.

![subgraph-conversion-inverse](https://github.com/user-attachments/assets/4fcaffca-1c97-4d71-93f7-1af569b1c941)
### Clean up old output links when unpacking
Unpacked nodes were left with dangling outputs. This would cause
cascading issues later, such as when consecutively unpacking nested
subgraphs.
### Minor refactoring for code clarity

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4964-Bundled-subgraph-fixes-24e6d73d365081d3a043ef1531d9d38a)
by [Unito](https://www.unito.io)
2025-08-13 13:04:44 -07:00
pythongosssss
efc0431a5e Update side toolbar menu (#4946)
Side toolbar menu UI updates

## Summary

- Currently the template modal is very hidden. Many users do not find it
- The current icons are quite aleatory 

## Changes

 **What**: 
- Add templates shortcut button
- Add item label in normal size
- Use custom icon

Critical design decisions or edge cases that need attention:
- Sidebar tabs registered using custom icons will have their associated
command registed with an undefined icon (currently only string icons are
accepted, not components). I couldn't see anywhere directly using this
icon, but we should consider autogenerating an icon font so we can use
classes for our custom icons (or locating and updating locations to
support both icon types)

## Screenshots (if applicable)
Normal mode:
<img width="621" height="1034" alt="image"
src="https://github.com/user-attachments/assets/c1d1cee2-004e-4ff8-b3fa-197329b0d2ae"
/>

Small mode:
<img width="176" height="325" alt="image"
src="https://github.com/user-attachments/assets/3824b8f6-bc96-4e62-aece-f0265113d2e3"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4946-Update-side-toolbar-menu-24d6d73d365081c5b2bdc0ee8b61dc50)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 11:46:03 -07:00
snomiao
1784e2b5a3 [feat] Add alternative package manager lockfiles to .gitignore (#4961)
## Summary
- Add bun.lock, bun.lockb, pnpm-lock.yaml, and yarn.lock to .gitignore
- Allows users to use faster package managers (Bun, pnpm) without making
git status dirty
- Maintains npm as the default while supporting developer choice of
package manager

## Test plan
- [x] Verify .gitignore changes are correct
- [ ] Test that creating these lockfiles doesn't show in git status
- [ ] Confirm existing npm functionality remains unaffected

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4961-feat-Add-alternative-package-manager-lockfiles-to-gitignore-24e6d73d3650817c8fa4fb8e94df5ac6)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-13 09:08:37 -07:00
snomiao
39df4ac9da [feat] Add Linux core dump to .gitignore (#4960)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 07:35:45 -07:00
AustinMroz
eba0b42674 Trigger updateSelectedItems on subgraph conversion (#4949)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 20:16:48 -07:00
Alexander Piskun
ef1852d551 gemini-2.5-pro and flash models; corrected prices (#4945) 2025-08-12 14:52:44 -07:00
Alexander Piskun
983ebb2ba7 pricing update for MinimaxHailuoVideo node and Kling "kling-v2-1" model (#4938) 2025-08-12 14:15:06 -07:00
AustinMroz
db71365768 Implement subgraph unpacking (#4840) 2025-08-12 13:45:29 -07:00
Christian Byrne
17d7ba8bcb [ci] Add lint:fix:no-cache script for consistency (#4948)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 13:25:22 -07:00
Christian Byrne
24a386c766 fix: add cleanup for graphChanged listener in useWorkflowPersistence (#4947) 2025-08-12 12:27:04 -07:00
Christian Byrne
c42c9315f4 [refactor] Replace lodash with es-toolkit (#4935) 2025-08-12 12:22:09 -07:00
Sidharth
d068b8351e Feat: add duplicate workflow on sidebar (#4895) 2025-08-12 12:04:29 -07:00
Christian Byrne
1cf8087be0 [ci] Optimize ESLint performance with caching and generated file excl… (#4926)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-12 13:31:54 -04:00
filtered
9c31d708a2 Add automatic trackpad / mouse sensing (#4913) 2025-08-12 10:15:32 -07:00
Comfy Org PR Bot
9a70e927aa 1.26.2 (#4939)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-12 00:52:23 -07:00
arab-future-academy
dc444faa75 Feature/arabic translation (#4916) 2025-08-11 23:29:01 -07:00
Jennifer Weber
a055ec2dff [bugfix] Fix queue not updating with completed task images (#4936)
Co-authored-by: Jennifer Weber <weberjc@MacBookPro.lan>
2025-08-11 22:36:43 -07:00
Christian Byrne
2138ceea80 [fix] ensure consistent link release behavior for subgraph IO nodes (#4931) 2025-08-11 19:28:15 -07:00
Christian Byrne
7972550f6b [fix] Fix link deletion from middle button when connected to reroute nodes deletes wrong link (#4928)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 19:26:37 -07:00
Chenlei Hu
c7baf3c340 [feat] Add knip for unused code detection (#4890) 2025-08-11 19:23:08 -07:00
Terry Jia
8403bd0e3e minimap improve (#4679)
Co-authored-by: github-actions <github-actions@github.com>
2025-08-11 14:04:02 -07:00
Christian Byrne
90f54414ab fix: Multiple links from reroute create single slot on SubgraphOutputNode (#4915)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 13:12:31 -07:00
Christian Byrne
505c242ff4 [refactor] Replace stringOrEmpty with lodash toString (#4917)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 11:48:59 -07:00
Christian Byrne
fbc6edde25 [feat] Add red styling to Remove Slot context menu option (#4918)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-11 11:48:02 -07:00
AustinMroz
2c215a6251 Fix subgraph reroute serialization (#4911) 2025-08-11 11:46:32 -07:00
189 changed files with 16465 additions and 1565 deletions

View File

@@ -502,11 +502,94 @@ echo "Workflow triggered. Waiting for PR creation..."
## Files Generated
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
- \`post-release-checklist.md\` - Follow-up tasks
- \`gtm-summary-${NEW_VERSION}.md\` - Marketing team notification
EOF
```
4. **RELEASE COMPLETION**: All post-release setup completed?
### Step 16: Generate GTM Feature Summary
1. **Extract and analyze PR data:**
```bash
echo "📊 Checking for marketing-worthy features..."
# Extract all PR data inline
PR_DATA=$(
PR_LIST=$(git log ${BASE_TAG}..HEAD --grep="Merge pull request" --pretty=format:"%s" | grep -oE "#[0-9]+" | tr -d '#' | sort -u)
echo "["
first=true
for PR in $PR_LIST; do
[[ "$first" == true ]] && first=false || echo ","
gh pr view $PR --json number,title,author,body,files,labels,closedAt 2>/dev/null || continue
done
echo "]"
)
# Save for analysis
echo "$PR_DATA" > prs-${NEW_VERSION}.json
```
2. **Analyze for GTM-worthy features:**
```
<task>
Review these PRs to identify if ANY would interest a marketing/growth team.
Consider if a PR:
- Changes something users directly interact with or experience
- Makes something noticeably better, faster, or easier
- Introduces capabilities users have been asking for
- Has visual assets (screenshots, GIFs, videos) that could be shared
- Tells a compelling story about improvement or innovation
- Would make users excited if they heard about it
Many releases contain only technical improvements, bug fixes, or internal changes -
that's perfectly normal. Only flag PRs that would genuinely interest end users.
If you find marketing-worthy PRs, note:
- PR number, title, and author
- Any media links from the description
- One sentence on why it's worth showcasing
If nothing is marketing-worthy, just say "No marketing-worthy features in this release."
</task>
PR data: [contents of prs-${NEW_VERSION}.json]
```
3. **Generate GTM notification (only if needed):**
```
If there are marketing-worthy features, create a message for #gtm with:
🚀 Frontend Release v${NEW_VERSION}
Timeline: Available now in nightly, ~2-3 weeks for core
Features worth showcasing:
[List the selected PRs with media links and authors]
Testing: --front-end-version ${NEW_VERSION}
If there are NO marketing-worthy features, generate:
"No marketing-worthy features in v${NEW_VERSION} - mostly internal improvements and bug fixes."
```
4. **Save the output:**
```bash
# Claude generates the GTM summary and saves it
# Save to gtm-summary-${NEW_VERSION}.md
# Check if notification is needed
if grep -q "No marketing-worthy features" gtm-summary-${NEW_VERSION}.md; then
echo "✅ No GTM notification needed for this release"
echo "📄 Summary saved to: gtm-summary-${NEW_VERSION}.md"
else
echo "📋 GTM summary saved to: gtm-summary-${NEW_VERSION}.md"
echo "📤 Share this file in #gtm channel to notify the team"
fi
```
## Advanced Safety Features
### Rollback Procedures

View File

@@ -49,7 +49,7 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
## Development Guidelines
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
2. Use es-toolkit for utility functions
3. Use TypeScript for type safety
4. Implement proper props and emits definitions
5. Utilize Vue 3's Teleport component when needed

View File

@@ -18,7 +18,7 @@ Use Tailwind CSS for styling
Leverage VueUse functions for performance-enhancing styles
Use lodash for utility functions
Use es-toolkit for utility functions
Use TypeScript for type safety

View File

@@ -56,6 +56,7 @@ jobs:
run: |
npm run lint
npm run format:check
npm run knip
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository

View File

@@ -61,6 +61,11 @@ jobs:
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated ComfyUI-Manager API types..."
npm run lint:fix:no-cache -- ./src/types/generatedManagerTypes.ts
- name: Check for changes
id: check-changes
run: |

View File

@@ -61,6 +61,11 @@ jobs:
exit 1
fi
- name: Lint generated types
run: |
echo "Linting generated Comfy Registry API types..."
npm run lint:fix:no-cache -- ./src/types/comfyRegistryTypes.ts
- name: Check for changes
id: check-changes
run: |

14
.gitignore vendored
View File

@@ -7,6 +7,15 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Package manager lockfiles (allow users to use different package managers)
bun.lock
bun.lockb
pnpm-lock.yaml
yarn.lock
# ESLint cache
.eslintcache
node_modules
dist
dist-ssr
@@ -58,5 +67,8 @@ dist.zip
# Temporary repository directory
templates_repo/
# Vites timestamped config modules
# Vite's timestamped config modules
vite.config.mts.timestamp-*.mjs
# Linux core dumps
./core

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -75,7 +75,7 @@ The development of successive minor versions overlaps. For example, while versio
<summary>v1.5: Native translation (i18n)</summary>
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, Korean, or Arabic. This native
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
More details available here: https://blog.comfy.org/p/native-localization-support-i18n

View File

@@ -0,0 +1,259 @@
{
"id": "dec788c2-9829-4a5d-a1ee-d6f0a678b42a",
"revision": 0,
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [6]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [415, 186],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [4]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473, 609],
"size": [315, 106],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [2]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [863, 186],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209, 188],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1451, 189],
"size": [210, 58],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [26, 474],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [1]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
}
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"reroutes": [
{
"id": 1,
"pos": [372.66668701171875, 415.33331298828125],
"linkIds": [3]
}
],
"linkExtensions": [
{
"id": 3,
"parentId": 1
}
],
"frontendVersion": "1.26.1"
},
"version": 0.4
}

View File

@@ -50,7 +50,7 @@ export class Topbar {
workflowName: string,
command: 'Save' | 'Save As' | 'Export'
) {
await this.triggerTopbarCommand(['Workflow', command])
await this.triggerTopbarCommand(['File', command])
await this.getSaveDialog().fill(workflowName)
await this.page.keyboard.press('Enter')
@@ -72,8 +72,8 @@ export class Topbar {
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
if (path.length < 1) {
throw new Error('Path cannot be empty')
}
const menu = await this.openTopbarMenu()
@@ -85,6 +85,13 @@ export class Topbar {
.locator('.p-tieredmenu-item')
.filter({ has: topLevelMenuItem })
await topLevelMenu.waitFor({ state: 'visible' })
// Handle top-level commands (like "New")
if (path.length === 1) {
await topLevelMenuItem.click()
return
}
await topLevelMenu.hover()
let currentMenu = topLevelMenu

View File

@@ -1,5 +1,5 @@
import _ from 'es-toolkit/compat'
import fs from 'fs'
import _ from 'lodash'
import path from 'path'
import type { Request, Route } from 'playwright'
import { v4 as uuidv4 } from 'uuid'
@@ -75,7 +75,9 @@ export default class TaskHistory {
private async handleGetView(route: Route) {
const fileName = getFilenameParam(route.request())
if (!this.outputContentTypes.has(fileName)) route.continue()
if (!this.outputContentTypes.has(fileName)) {
return route.continue()
}
const asset = this.loadAsset(fileName)
return route.fulfill({

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test.skip('Can be added to canvas using node library sidebar', async ({
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test.skip('Manage group opens with the correct group selected', async ({
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
@@ -268,10 +268,7 @@ test.describe('Group Node', () => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
@@ -280,7 +277,7 @@ test.describe('Group Node', () => {
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
@@ -296,7 +293,7 @@ test.describe('Group Node', () => {
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()

View File

@@ -684,7 +684,7 @@ test.describe('Load workflow', () => {
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading

View File

@@ -75,7 +75,7 @@ test.describe('Menu', () => {
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'

View File

@@ -24,8 +24,14 @@ test.describe('Minimap', () => {
const minimapViewport = minimapContainer.locator('.minimap-viewport')
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'absolute')
await expect(minimapContainer).toHaveCSS('z-index', '1000')
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
'.minimap-main-container'
)
await expect(minimapMainContainer).toHaveCSS('position', 'absolute')
await expect(minimapMainContainer).toHaveCSS('z-index', '1000')
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {

View File

@@ -18,7 +18,7 @@ test.describe('Reroute Node', () => {
[workflowName]: workflowName
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
// Insert the workflow
const workflowsTab = comfyPage.menu.workflowsTab
@@ -100,4 +100,29 @@ test.describe('LiteGraph Native Reroute Node', () => {
'native_reroute_context_menu.png'
)
})
test('Can delete link that is connected to two reroutes', async ({
comfyPage
}) => {
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
await comfyPage.loadWorkflow(
'reroute/single-native-reroute-default-workflow'
)
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
// since the link is a bezier curve and not a straight line.
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
// Click the middle point of the link to open the context menu.
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
// Click the "Delete" context menu option.
await comfyPage.page
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'native_reroute_delete_from_midpoint_context_menu.png'
)
})
})

View File

@@ -24,11 +24,11 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test.skip('Can convert to group node', async ({ comfyPage }) => {
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Convert to Group Node')
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -317,6 +317,25 @@ test.describe('Workflows sidebar', () => {
])
})
test('Can duplicate workflow from context menu', async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'
})
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.clickContextMenuItem('Duplicate')
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*workflow1 (Copy).json'
])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'

View File

@@ -63,7 +63,7 @@ test.describe('Workflow Tab Thumbnails', () => {
test('Should show thumbnail when hovering over a non-active tab', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
0,
@@ -73,7 +73,7 @@ test.describe('Workflow Tab Thumbnails', () => {
})
test('Should not show thumbnail for active tab', async ({ comfyPage }) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
const thumbnailImg = await getTabThumbnailImage(
comfyPage,
1,
@@ -105,7 +105,7 @@ test.describe('Workflow Tab Thumbnails', () => {
await comfyPage.nextFrame()
// Create a new workflow (tab 1) which will be empty
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
// Now we have two tabs: tab 0 (default workflow with nodes) and tab 1 (empty)

View File

@@ -14,7 +14,10 @@ export default [
ignores: [
'src/scripts/*',
'src/extensions/core/*',
'src/types/vue-shim.d.ts'
'src/types/vue-shim.d.ts',
// Generated files that don't need linting
'src/types/comfyRegistryTypes.ts',
'src/types/generatedManagerTypes.ts'
]
},
{

75
knip.config.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
entry: [
'src/main.ts',
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts',
'eslint.config.js',
'tailwind.config.js',
'postcss.config.js',
'playwright.config.ts',
'playwright.i18n.config.ts',
'vitest.config.ts',
'scripts/**/*.{js,ts}'
],
project: [
'src/**/*.{js,ts,vue}',
'tests-ui/**/*.{js,ts,vue}',
'browser_tests/**/*.{js,ts}',
'scripts/**/*.{js,ts}'
],
ignore: [
// Generated files
'dist/**',
'types/**',
'node_modules/**',
// Config files that might not show direct usage
'.husky/**',
// Temporary or cache files
'.vite/**',
'coverage/**',
// i18n config
'.i18nrc.cjs',
// Test setup files
'browser_tests/globalSetup.ts',
'browser_tests/globalTeardown.ts',
'browser_tests/utils/**',
// Scripts
'scripts/**',
// Vite config files
'vite.electron.config.mts',
'vite.types.config.mts',
// Auto generated manager types
'src/types/generatedManagerTypes.ts'
],
ignoreExportsUsedInFile: true,
// Vue-specific configuration
vue: true,
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
binaries: 'off',
classMembers: 'off',
dependencies: 'off',
devDependencies: 'off',
duplicates: 'off',
enumMembers: 'off',
exports: 'off',
nsExports: 'off',
nsTypes: 'off',
types: 'off',
unlisted: 'off'
},
// Include dependencies analysis
includeEntryExports: true,
// Workspace configuration for monorepo-like structure
workspaces: {
'.': {
entry: ['src/main.ts']
}
}
}
export default config

565
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.1",
"version": "1.26.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.26.1",
"version": "1.26.3",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -30,12 +30,12 @@
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
@@ -62,7 +62,6 @@
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
@@ -80,6 +79,7 @@
"happy-dom": "^15.11.0",
"husky": "^9.0.11",
"identity-obj-proxy": "^3.0.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
@@ -1001,6 +1001,40 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
"integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
"integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz",
"integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -3069,6 +3103,19 @@
"node": ">=18"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.3.tgz",
"integrity": "sha512-rZxtMsLwjdXkMUGC3WwsPwLNVqVqnTJT6MNIB6e+5fhMcSCPP0AOsNWuMQ5mdCq6HNjs/ZeWAEchpqeprqBD2Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.5",
"@emnapi/runtime": "^1.4.5",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3120,6 +3167,275 @@
"node": ">=8.0.0"
}
},
"node_modules/@oxc-resolver/binding-android-arm-eabi": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.6.1.tgz",
"integrity": "sha512-Ma/kg29QJX1Jzelv0Q/j2iFuUad1WnjgPjpThvjqPjpOyLjCUaiFCCnshhmWjyS51Ki1Iol3fjf1qAzObf8GIA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-android-arm64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.6.1.tgz",
"integrity": "sha512-xjL/FKKc5p8JkFWiH7pJWSzsewif3fRf1rw2qiRxRvq1uIa6l7Zoa14Zq2TNWEsqDjdeOrlJtfWiPNRnevK0oQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oxc-resolver/binding-darwin-arm64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.6.1.tgz",
"integrity": "sha512-u0yrJ3NHE0zyCjiYpIyz4Vmov21MA0yFKbhHgixDU/G6R6nvC8ZpuSFql3+7C8ttAK9p8WpqOGweepfcilH5Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-darwin-x64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.6.1.tgz",
"integrity": "sha512-2lox165h1EhzxcC8edUy0znXC/hnAbUPaMpYKVlzLpB2AoYmgU4/pmofFApj+axm2FXpNamjcppld8EoHo06rw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oxc-resolver/binding-freebsd-x64": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.6.1.tgz",
"integrity": "sha512-F45MhEQ7QbHfsvZtVNuA/9obu3il7QhpXYmCMfxn7Zt9nfAOw4pQ8hlS5DroHVp3rW35u9F7x0sixk/QEAi3qQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.6.1.tgz",
"integrity": "sha512-r+3+MTTl0tD4NoWbfTIItAxJvuyIU7V0fwPDXrv7Uj64vZ3OYaiyV+lVaeU89Bk/FUUQxeUpWBwdKNKHjyRNQw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.6.1.tgz",
"integrity": "sha512-TBTZ63otsWZ72Z8ZNK2JVS0HW1w9zgOixJTFDNrYPUUW1pXGa28KAjQ1yGawj242WLAdu3lwdNIWtkxeO2BLxQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.6.1.tgz",
"integrity": "sha512-SjwhNynjSG2yMdyA0f7wz7Yvo3ppejO+ET7n2oiI7ApCXrwxMzeRWjBzQt+oVWr2HzVOfaEcDS9rMtnR83ulig==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-arm64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.6.1.tgz",
"integrity": "sha512-f4EMidK6rosInBzPMnJ0Ri4RttFCvvLNUNDFUBtELW/MFkBwPTDlvbsmW0u0Mk/ruBQ2WmRfOZ6tT62kWMcX2Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.6.1.tgz",
"integrity": "sha512-1umENVKeUsrWnf5IlF/6SM7DCv8G6CoKI2LnYR6qhZuLYDPS4PBZ0Jow3UDV9Rtbv5KRPcA3/uXjI88ntWIcOQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.6.1.tgz",
"integrity": "sha512-Hjyp1FRdJhsEpIxsZq5VcDuFc8abC0Bgy8DWEa31trCKoTz7JqA7x3E2dkFbrAKsEFmZZ0NvuG5Ip3oIRARhow==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.6.1.tgz",
"integrity": "sha512-ODJOJng6f3QxpAXhLel3kyWs8rPsJeo9XIZHzA7p//e+5kLMDU7bTVk4eZnUHuxsqsB8MEvPCicJkKCEuur5Ag==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.6.1.tgz",
"integrity": "sha512-hCzRiLhqe1ZOpHTsTGKp7gnMJRORlbCthawBueer2u22RVAka74pV/+4pP1tqM07mSlQn7VATuWaDw9gCl+cVg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-gnu": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.6.1.tgz",
"integrity": "sha512-JansPD8ftOzMYIC3NfXJ68tt63LEcIAx44Blx6BAd7eY880KX7A0KN3hluCrelCz5aQkPaD95g8HBiJmKaEi2w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-linux-x64-musl": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.6.1.tgz",
"integrity": "sha512-R78ES1rd4z2x5NrFPtSWb/ViR1B8wdl+QN2X8DdtoYcqZE/4tvWtn9ZTCXMEzUp23tchJ2wUB+p6hXoonkyLpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oxc-resolver/binding-wasm32-wasi": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.6.1.tgz",
"integrity": "sha512-qAR3tYIf3afkij/XYunZtlz3OH2Y4ni10etmCFIJB5VRGsqJyI6Hl+2dXHHGJNwbwjXjSEH/KWJBpVroF3TxBw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.0.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.6.1.tgz",
"integrity": "sha512-QqygWygIuemGkaBA48POOTeinbVvlamqh6ucm8arGDGz/mB5O00gXWxed12/uVrYEjeqbMkla/CuL3fjL3EKvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-ia32-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.6.1.tgz",
"integrity": "sha512-N2+kkWwt/bk0JTCxhPuK8t8JMp3nd0n2OhwOkU8KO4a7roAJEa4K1SZVjMv5CqUIr5sx2CxtXRBoFDiORX5oBg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oxc-resolver/binding-win32-x64-msvc": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.6.1.tgz",
"integrity": "sha512-DfMg3cU9bJUbN62Prbp4fGCtLgexuwyEaQGtZAp8xmi1Ii26uflOGx0FJkFTF6lVMSFoIRFvIL8gsw5/ZdHrMw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pinia/testing": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.5.tgz",
@@ -4486,6 +4802,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/argparse": {
"version": "1.0.38",
"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
@@ -4559,12 +4886,6 @@
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.6",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz",
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==",
"dev": true
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@@ -7670,6 +7991,15 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.39.9",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz",
"integrity": "sha512-9OtbkZmTA2Qc9groyA1PUNeb6knVTkvB2RSdr/LcJXDL8IdEakaxwXLHXa7VX/Wj0GmdMJPR3WhnPGhiP3E+qg==",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -8469,16 +8799,17 @@
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.4"
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
@@ -8551,6 +8882,16 @@
"node": ">=0.8.0"
}
},
"node_modules/fd-package-json": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"walk-up-path": "^4.0.0"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -8846,6 +9187,22 @@
"node": ">=0.4.x"
}
},
"node_modules/formatly": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/formatly/-/formatly-0.2.4.tgz",
"integrity": "sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fd-package-json": "^2.0.0"
},
"bin": {
"formatly": "bin/index.mjs"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
@@ -10517,6 +10874,109 @@
"node": ">=0.10.0"
}
},
"node_modules/knip": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-5.62.0.tgz",
"integrity": "sha512-hfTUVzmrMNMT1khlZfAYmBABeehwWUUrizLQoLamoRhSFkygsGIXWx31kaWKBgEaIVL77T3Uz7IxGvSw+CvQ6A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/webpro"
},
{
"type": "opencollective",
"url": "https://opencollective.com/knip"
},
{
"type": "polar",
"url": "https://polar.sh/webpro-nl"
}
],
"license": "ISC",
"dependencies": {
"@nodelib/fs.walk": "^1.2.3",
"fast-glob": "^3.3.3",
"formatly": "^0.2.4",
"jiti": "^2.4.2",
"js-yaml": "^4.1.0",
"minimist": "^1.2.8",
"oxc-resolver": "^11.1.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.1",
"smol-toml": "^1.3.4",
"strip-json-comments": "5.0.2",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
},
"bin": {
"knip": "bin/knip.js",
"knip-bun": "bin/knip-bun.js"
},
"engines": {
"node": ">=18.18.0"
},
"peerDependencies": {
"@types/node": ">=18",
"typescript": ">=5.0.4"
}
},
"node_modules/knip/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/knip/node_modules/jiti": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/knip/node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/knip/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/knip/node_modules/strip-json-comments": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.2.tgz",
"integrity": "sha512-4X2FR3UwhNUE9G49aIsJW5hRRR3GXGTBTZRMfv568O60ojM8HcWjV/VxAxCDW3SUND33O6ZY66ZuRcdkj73q2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -10992,7 +11452,8 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
@@ -12388,6 +12849,22 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-postinstall": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz",
"integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==",
"dev": true,
"license": "MIT",
"bin": {
"napi-postinstall": "lib/cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/napi-postinstall"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12741,6 +13218,41 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/oxc-resolver": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.6.1.tgz",
"integrity": "sha512-WQgmxevT4cM5MZ9ioQnEwJiHpPzbvntV5nInGAKo9NQZzegcOonHvcVcnkYqld7bTG35UFHEKeF7VwwsmA3cZg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"napi-postinstall": "^0.3.0"
},
"funding": {
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxc-resolver/binding-android-arm-eabi": "11.6.1",
"@oxc-resolver/binding-android-arm64": "11.6.1",
"@oxc-resolver/binding-darwin-arm64": "11.6.1",
"@oxc-resolver/binding-darwin-x64": "11.6.1",
"@oxc-resolver/binding-freebsd-x64": "11.6.1",
"@oxc-resolver/binding-linux-arm-gnueabihf": "11.6.1",
"@oxc-resolver/binding-linux-arm-musleabihf": "11.6.1",
"@oxc-resolver/binding-linux-arm64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-arm64-musl": "11.6.1",
"@oxc-resolver/binding-linux-ppc64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-riscv64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-riscv64-musl": "11.6.1",
"@oxc-resolver/binding-linux-s390x-gnu": "11.6.1",
"@oxc-resolver/binding-linux-x64-gnu": "11.6.1",
"@oxc-resolver/binding-linux-x64-musl": "11.6.1",
"@oxc-resolver/binding-wasm32-wasi": "11.6.1",
"@oxc-resolver/binding-win32-arm64-msvc": "11.6.1",
"@oxc-resolver/binding-win32-ia32-msvc": "11.6.1",
"@oxc-resolver/binding-win32-x64-msvc": "11.6.1"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -14804,6 +15316,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/smol-toml": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -17400,6 +17925,16 @@
"node": ">=14"
}
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.1",
"version": "1.26.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -21,8 +21,11 @@
"test:component": "vitest run src/components/",
"prepare": "husky || true",
"preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint": "eslint src --cache",
"lint:fix": "eslint src --cache --fix",
"lint:no-cache": "eslint src",
"lint:fix:no-cache": "eslint src --fix",
"knip": "knip",
"locale": "lobe-i18n locale",
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
"json-schema": "tsx scripts/generate-json-schema.ts"
@@ -38,7 +41,6 @@
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
@@ -56,6 +58,7 @@
"happy-dom": "^15.11.0",
"husky": "^9.0.11",
"identity-obj-proxy": "^3.0.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
@@ -96,12 +99,12 @@
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
"extendable-media-recorder-wav-encoder": "^7.0.129",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",

View File

@@ -51,7 +51,7 @@ const template = await fetch('/templates/default.json')
## General Guidelines
- Use lodash for utility functions
- Use es-toolkit for utility functions
- Implement proper TypeScript types
- Follow Vue 3 composition API style guide
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.91396 12.7428L5.41396 10.7428C5.57175 10.1116 5.09439 9.50024 4.44382 9.50024H2.50538C2.04651 9.50024 1.64652 9.81253 1.53523 10.2577L1.03523 12.2577C0.877446 12.8888 1.3548 13.5002 2.00538 13.5002H3.94382C4.40269 13.5002 4.80267 13.1879 4.91396 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.91396 6.74277L6.41396 4.74277C6.57175 4.11163 6.09439 3.50024 5.44382 3.50024H3.50538C3.04651 3.50024 2.64652 3.81253 2.53523 4.2577L2.03523 6.2577C1.87745 6.88885 2.3548 7.50024 3.00538 7.50024H4.94382C5.40269 7.50024 5.80267 7.18794 5.91396 6.74277Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10.914 12.7428L11.414 10.7428C11.5718 10.1116 11.0944 9.50024 10.4438 9.50024H8.50538C8.04651 9.50024 7.64652 9.81253 7.53523 10.2577L7.03523 12.2577C6.87745 12.8888 7.3548 13.5002 8.00538 13.5002H9.94382C10.4027 13.5002 10.8027 13.1879 10.914 12.7428Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M12.2342 5.46739L11.5287 7.11354C11.4248 7.35597 11.0811 7.35597 10.9772 7.11354L10.2717 5.46739C10.2414 5.39659 10.185 5.34017 10.1141 5.30983L8.468 4.60433C8.22557 4.50044 8.22557 4.15675 8.468 4.05285L10.1141 3.34736C10.185 3.31701 10.2414 3.26059 10.2717 3.18979L10.9772 1.54364C11.0811 1.30121 11.4248 1.30121 11.5287 1.54364L12.2342 3.18979C12.2645 3.26059 12.3209 3.31701 12.3918 3.34736L14.0379 4.05285C14.2803 4.15675 14.2803 4.50044 14.0379 4.60433L12.3918 5.30983C12.3209 5.34017 12.2645 5.39659 12.2342 5.46739Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 10L10.598 10.2577C10.4812 10.6954 10.0848 11 9.63172 11H5.30161C4.64458 11 4.16608 10.3772 4.33538 9.74234L5.40204 5.74234C5.51878 5.30458 5.91523 5 6.36828 5H10.8286C11.4199 5 11.8505 5.56051 11.6982 6.13185L11.6736 6.22389M14 8H10M4.5 8H2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1894 6.24254L13.6894 4.24254C13.8471 3.61139 13.3698 3 12.7192 3H3.78077C3.3219 3 2.92192 3.3123 2.81062 3.75746L2.31062 5.75746C2.15284 6.38861 2.63019 7 3.28077 7H12.2192C12.6781 7 13.0781 6.6877 13.1894 6.24254Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M13.1894 12.2425L13.6894 10.2425C13.8471 9.61139 13.3698 9 12.7192 9H8.78077C8.3219 9 7.92192 9.3123 7.81062 9.75746L7.31062 11.7575C7.15284 12.3886 7.6302 13 8.28077 13H12.2192C12.6781 13 13.0781 12.6877 13.1894 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M5.18936 12.2425L5.68936 10.2425C5.84714 9.61139 5.36978 9 4.71921 9H3.78077C3.3219 9 2.92192 9.3123 2.81062 9.75746L2.31062 11.7575C2.15284 12.3886 2.6302 13 3.28077 13H4.21921C4.67808 13 5.07806 12.6877 5.18936 12.2425Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 970 B

View File

@@ -20,7 +20,7 @@ import {
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'lodash'
import { clamp } from 'es-toolkit/compat'
import Panel from 'primevue/panel'
import { Ref, computed, inject, nextTick, onMounted, ref, watch } from 'vue'

View File

@@ -1,75 +0,0 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex justify-between text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
<div>{{ t('apiNodesCostBreakdown.costPerRun') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="node in nodes"
:key="node.name"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
node.name
}}</span>
</div>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-base font-medium leading-tight">
{{ node.cost.toFixed(costPrecision) }}
</span>
</div>
</div>
</div>
</ScrollPanel>
<template v-if="showTotal && nodes.length > 1">
<Divider class="my-2" />
<div class="flex justify-between items-center border-t px-3">
<span class="text-sm">{{ t('apiNodesCostBreakdown.totalCost') }}</span>
<div class="flex items-center gap-1">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-yellow-500 p-1"
/>
<span>{{ totalCost.toFixed(costPrecision) }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ApiNodeCost } from '@/types/apiNodeTypes'
const { t } = useI18n()
const {
nodes,
showTotal = true,
costPrecision = 3
} = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
costPrecision?: number
}>()
const totalCost = computed(() =>
nodes.reduce((sum, node) => sum + node.cost, 0)
)
</script>

View File

@@ -1,31 +0,0 @@
<template>
<div class="flex flex-col gap-3 h-full">
<div class="flex text-xs">
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
</div>
<ScrollPanel class="flex-grow h-0">
<div class="flex flex-col gap-2">
<div
v-for="nodeName in nodeNames"
:key="nodeName"
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
>
<div class="flex items-center gap-2">
<span class="text-base font-medium leading-tight">{{
nodeName
}}</span>
</div>
</div>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { nodeNames } = defineProps<{ nodeNames: string[] }>()
</script>

View File

@@ -42,7 +42,7 @@
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'

View File

@@ -16,7 +16,7 @@
<script setup lang="ts" generic="T">
import { useElementSize, useScroll, whenever } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { clamp, debounce } from 'es-toolkit/compat'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
type GridState = {

View File

@@ -36,6 +36,7 @@ import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/stores/settingStore'

View File

@@ -169,8 +169,8 @@ import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import _ from 'es-toolkit/compat'
import { cloneDeep } from 'es-toolkit/compat'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import Dropdown from 'primevue/dropdown'

View File

@@ -93,7 +93,7 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import { merge } from 'es-toolkit/compat'
import Button from 'primevue/button'
import {
computed,

View File

@@ -12,7 +12,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import PackEnableToggle from './PackEnableToggle.vue'
// Mock debounce to execute immediately
vi.mock('lodash', () => ({
vi.mock('es-toolkit/compat', () => ({
debounce: <T extends (...args: any[]) => any>(fn: T) => fn
}))

View File

@@ -10,7 +10,7 @@
</template>
<script setup lang="ts">
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'

View File

@@ -1,46 +0,0 @@
<template>
<div class="w-[100%] flex justify-between items-center">
<div class="flex justify-start items-center">
<div class="w-1 h-6 rounded-md" />
<div class="w-6 h-6 relative overflow-hidden">
<i class="pi pi-box text-xl text-muted" style="opacity: 0.6" />
</div>
<div class="px-3 py-2 rounded-md flex justify-start items-start gap-2.5">
<div class="text-right justify-start text-sm font-bold leading-none">
{{ $t('manager.nodePack') }}
</div>
</div>
</div>
<div class="inline-flex justify-start items-center gap-3">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
</script>

View File

@@ -57,7 +57,7 @@
</template>
<script setup lang="ts">
import { stubTrue } from 'lodash'
import { stubTrue } from 'es-toolkit/compat'
import AutoComplete, {
AutoCompleteOptionSelectEvent
} from 'primevue/autocomplete'

View File

@@ -1,39 +1,68 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute right-[90px] z-[1000]"
:class="{
'bottom-[20px]': !bottomPanelStore.bottomPanelVisible,
'bottom-[280px]': bottomPanelStore.bottomPanelVisible
}"
:style="containerStyles"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
<MiniMapPanel
v-if="showOptionsPanel"
:panel-styles="panelStyles"
:node-colors="nodeColors"
:show-links="showLinks"
:show-groups="showGroups"
:render-bypass="renderBypass"
:render-error="renderError"
@update-option="updateOption"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
ref="containerRef"
class="litegraph-minimap relative"
:style="containerStyles"
>
<Button
class="absolute z-10"
size="small"
text
severity="secondary"
@click.stop="toggleOptionsPanel"
>
<template #icon>
<i-lucide:settings-2 />
</template>
</Button>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
<div
class="absolute inset-0"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@wheel="handleWheel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const bottomPanelStore = useBottomPanelStore()
const {
initialized,
@@ -44,6 +73,13 @@ const {
viewportStyles,
width,
height,
panelStyles,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
@@ -52,6 +88,12 @@ const {
handleWheel
} = minimap
const showOptionsPanel = ref(false)
const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
watch(
() => canvasStore.canvas,
async (canvas) => {

View File

@@ -0,0 +1,97 @@
<template>
<div
class="minimap-panel p-3 mr-2 flex flex-col gap-3 text-sm"
:style="panelStyles"
>
<div class="flex items-center gap-2">
<Checkbox
input-id="node-colors"
name="node-colors"
:model-value="nodeColors"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.NodeColors', value)
"
/>
<i-lucide:palette />
<label for="node-colors">{{ $t('minimap.nodeColors') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-links"
name="show-links"
:model-value="showLinks"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowLinks', value)
"
/>
<i-lucide:route />
<label for="show-links">{{ $t('minimap.showLinks') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="show-groups"
name="show-groups"
:model-value="showGroups"
binary
@update:model-value="
(value) => $emit('updateOption', 'Comfy.Minimap.ShowGroups', value)
"
/>
<i-lucide:frame />
<label for="show-groups">{{ $t('minimap.showGroups') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-bypass"
name="render-bypass"
:model-value="renderBypass"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderBypassState', value)
"
/>
<i-lucide:circle-slash-2 />
<label for="render-bypass">{{ $t('minimap.renderBypassState') }}</label>
</div>
<div class="flex items-center gap-2">
<Checkbox
input-id="render-error"
name="render-error"
:model-value="renderError"
binary
@update:model-value="
(value) =>
$emit('updateOption', 'Comfy.Minimap.RenderErrorState', value)
"
/>
<i-lucide:message-circle-warning />
<label for="render-error">{{ $t('minimap.renderErrorState') }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { MinimapOptionKey } from '@/composables/useMinimap'
defineProps<{
panelStyles: any
nodeColors: boolean
showLinks: boolean
showGroups: boolean
renderBypass: boolean
renderError: boolean
}>()
defineEmits<{
updateOption: [key: MinimapOptionKey, value: boolean]
}>()
</script>

View File

@@ -1,6 +1,20 @@
<template>
<Button
v-show="isVisible"
v-if="isUnpackVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_UnpackSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
<template #icon>
<i-lucide:expand />
</template>
</Button>
<Button
v-else-if="isConvertVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
@@ -20,6 +34,7 @@ import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -27,7 +42,13 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
const isUnpackVisible = computed(() => {
return (
canvasStore.selectedItems?.length === 1 &&
canvasStore.selectedItems[0] instanceof SubgraphNode
)
})
const isConvertVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||

View File

@@ -82,7 +82,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
</template>
<script setup lang="ts">
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import { computed } from 'vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'

View File

@@ -8,10 +8,13 @@
:icon-badge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip-suffix="getTabTooltipSuffix(tab)"
:label="tab.label || tab.title"
:is-small="isSmall"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
/>
<SidebarTemplatesButton />
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarHelpCenterIcon />
@@ -43,6 +46,7 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -86,7 +90,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
box-shadow: var(--bar-shadow);
--sidebar-width: 4rem;
--sidebar-icon-size: 1.5rem;
--sidebar-icon-size: 1rem;
}
.side-tool-bar-container.small-sidebar {

View File

@@ -58,11 +58,12 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import HelpCenterMenuContent from '@/components/helpcenter/HelpCenterMenuContent.vue'
import ReleaseNotificationToast from '@/components/helpcenter/ReleaseNotificationToast.vue'
import WhatsNewPopup from '@/components/helpcenter/WhatsNewPopup.vue'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -70,8 +71,9 @@ import SidebarIcon from './SidebarIcon.vue'
const settingStore = useSettingStore()
const releaseStore = useReleaseStore()
const helpCenterStore = useHelpCenterStore()
const { shouldShowRedDot } = storeToRefs(releaseStore)
const isHelpCenterVisible = ref(false)
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
const sidebarLocation = computed(() =>
settingStore.get('Comfy.Sidebar.Location')
@@ -80,11 +82,11 @@ const sidebarLocation = computed(() =>
const sidebarSize = computed(() => settingStore.get('Comfy.Sidebar.Size'))
const toggleHelpCenter = () => {
isHelpCenterVisible.value = !isHelpCenterVisible.value
helpCenterStore.toggle()
}
const closeHelpCenter = () => {
isHelpCenterVisible.value = false
helpCenterStore.hide()
}
// Initialize release store on mount
@@ -130,6 +132,7 @@ onMounted(async () => {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);

View File

@@ -19,12 +19,29 @@
@click="emit('click', $event)"
>
<template #icon>
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
<div class="side-bar-button-content">
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
}}</span>
</div>
</template>
</Button>
</template>
@@ -33,6 +50,7 @@
import Button from 'primevue/button'
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -41,13 +59,17 @@ const {
selected = false,
tooltip = '',
tooltipSuffix = '',
iconBadge = ''
iconBadge = '',
label = '',
isSmall = false
} = defineProps<{
icon?: string
icon?: string | Component
selected?: boolean
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
label?: string
isSmall?: boolean
}>()
const emit = defineEmits<{
@@ -74,10 +96,23 @@ const computedTooltip = computed(() => t(tooltip) + tooltipSuffix)
<style scoped>
.side-bar-button {
width: var(--sidebar-width);
height: var(--sidebar-width);
height: calc(var(--sidebar-width) + 0.5rem);
border-radius: 0;
}
.side-tool-bar-end .side-bar-button {
height: var(--sidebar-width);
}
.side-bar-button-content {
@apply flex flex-col items-center gap-2;
}
.side-bar-button-label {
@apply text-[10px] text-center whitespace-nowrap;
line-height: 1;
}
.comfyui-body-left .side-bar-button.side-bar-button-selected,
.comfyui-body-left .side-bar-button.side-bar-button-selected:hover {
border-left: 4px solid var(--p-button-text-primary-color);

View File

@@ -0,0 +1,35 @@
<template>
<SidebarIcon
:icon="TemplateIcon"
:tooltip="$t('sideToolbar.templates')"
:label="$t('sideToolbar.labels.templates')"
:is-small="isSmall"
class="templates-tab-button"
@click="openTemplates"
/>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import SidebarIcon from './SidebarIcon.vue'
// Import the custom template icon
const TemplateIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
)
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'
)
const openTemplates = () => {
void commandStore.execute('Comfy.BrowseTemplates')
}
</script>

View File

@@ -30,11 +30,18 @@
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
icon="pi pi-filter-slash"
text
severity="secondary"
@click="resetOrganization"
/>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
text
severity="secondary"
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
@@ -139,6 +146,7 @@ import {
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
@@ -155,6 +163,7 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
const commandStore = useCommandStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)

View File

@@ -265,6 +265,14 @@ const renderTreeNode = (
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.duplicateWorkflow(workflow)
}
}
]
},

View File

@@ -55,9 +55,30 @@
v-bind="props.action"
:href="item.url"
target="_blank"
:class="typeof item.class === 'function' ? item.class() : item.class"
@mousedown="
isZoomCommand(item) ? handleZoomMouseDown(item, $event) : undefined
"
@click="isZoomCommand(item) ? handleZoomClick($event) : undefined"
>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<i
v-if="hasActiveStateSiblings(item)"
class="p-menubar-item-icon pi pi-check text-sm"
:class="{ invisible: !item.comfyCommand?.active?.() }"
/>
<span
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
class="p-menubar-item-icon"
:class="item.icon"
/>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<i
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
class="ml-auto"
:class="item.icon"
/>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
@@ -94,6 +115,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
@@ -163,16 +185,22 @@ const extraMenuItems: MenuItem[] = [
},
{ separator: true },
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'pi pi-folder-open',
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
},
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
}
]
@@ -237,6 +265,36 @@ const onMenuShow = () => {
}
})
}
const isZoomCommand = (item: MenuItem) => {
return (
item.comfyCommand?.id === 'Comfy.Canvas.ZoomIn' ||
item.comfyCommand?.id === 'Comfy.Canvas.ZoomOut'
)
}
const handleZoomMouseDown = (item: MenuItem, event: MouseEvent) => {
if (item.comfyCommand) {
whileMouseDown(
event,
async () => {
await commandStore.execute(item.comfyCommand!.id)
},
50
)
}
}
const handleZoomClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// Prevent the menu from closing for zoom commands
return false
}
const hasActiveStateSiblings = (item: MenuItem): boolean => {
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
}
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import { Ref, markRaw, onMounted, onUnmounted } from 'vue'
export function useTerminal(element: Ref<HTMLElement | undefined>) {

View File

@@ -1,5 +1,5 @@
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import { debounce } from 'es-toolkit/compat'
import { readonly, ref } from 'vue'
/**

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'

View File

@@ -261,7 +261,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$0.14-2.80/Run (varies with model, mode & duration)'
const modelValue = String(modelWidget.value)
if (modelValue.includes('v2-master')) {
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
return '$1.40/Run'
} else if (
modelValue.includes('v1-6') ||
@@ -280,12 +283,19 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
console.log('durationValue', durationValue)
// Same pricing matrix as KlingTextToVideoNode
if (modelValue.includes('v2-master')) {
if (
modelValue.includes('v2-1-master') ||
modelValue.includes('v2-master')
) {
if (durationValue.includes('10')) {
return '$2.80/Run'
}
return '$1.40/Run' // 5s default
} else if (modelValue.includes('v1-6') || modelValue.includes('v1-5')) {
} else if (
modelValue.includes('v2-1') ||
modelValue.includes('v1-6') ||
modelValue.includes('v1-5')
) {
if (modeValue.includes('pro')) {
return durationValue.includes('10') ? '$0.98/Run' : '$0.49/Run'
} else {
@@ -418,7 +428,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modeValue = String(modeWidget.value)
// Pricing matrix from CSV data based on mode string content
if (modeValue.includes('v2-master')) {
if (modeValue.includes('v2-1-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run' // price is the same as for v2-master model
}
return '$1.40/Run' // price is the same as for v2-master model
} else if (modeValue.includes('v2-master')) {
if (modeValue.includes('10s')) {
return '$2.80/Run'
}
@@ -558,6 +573,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
MinimaxTextToVideoNode: {
displayPrice: '$0.43/Run'
},
MinimaxHailuoVideoNode: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!resolutionWidget || !durationWidget) {
return '$0.28-0.56/Run (varies with resolution & duration)'
}
const resolution = String(resolutionWidget.value)
const duration = String(durationWidget.value)
if (resolution.includes('768P')) {
if (duration.includes('6')) return '$0.28/Run'
if (duration.includes('10')) return '$0.56/Run'
} else if (resolution.includes('1080P')) {
if (duration.includes('6')) return '$0.49/Run'
}
return '$0.43/Run' // default median
}
},
OpenAIDalle2: {
displayPrice: (node: LGraphNode): string => {
const sizeWidget = node.widgets?.find(
@@ -1278,9 +1319,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-flash')) {
return '$0.0003/$0.0025 per 1K tokens'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00125/$0.01 per 1K tokens'
} else if (model.includes('gemini-2.5-pro')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
@@ -1358,6 +1403,7 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],

View File

@@ -1,4 +1,4 @@
import { groupBy } from 'lodash'
import { groupBy } from 'es-toolkit/compat'
import { computed, onMounted } from 'vue'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'

View File

@@ -1,16 +1,21 @@
import { markRaw } from 'vue'
import { defineAsyncComponent, markRaw } from 'vue'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'
const AiModelIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/ai-model'))
)
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'model-library',
icon: 'pi pi-box',
icon: AiModelIcon,
title: 'sideToolbar.modelLibrary',
tooltip: 'sideToolbar.modelLibrary',
label: 'sideToolbar.labels.models',
component: markRaw(ModelLibrarySidebarTab),
type: 'vue',
iconBadge: () => {

View File

@@ -1,14 +1,19 @@
import { markRaw } from 'vue'
import { defineAsyncComponent, markRaw } from 'vue'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const NodeIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/node'))
)
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
return {
id: 'node-library',
icon: 'pi pi-book',
icon: NodeIcon,
title: 'sideToolbar.nodeLibrary',
tooltip: 'sideToolbar.nodeLibrary',
label: 'sideToolbar.labels.nodes',
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}

View File

@@ -15,6 +15,7 @@ export const useQueueSidebarTab = (): SidebarTabExtension => {
},
title: 'sideToolbar.queue',
tooltip: 'sideToolbar.queue',
label: 'sideToolbar.labels.queue',
component: markRaw(QueueSidebarTab),
type: 'vue'
}

View File

@@ -1,16 +1,20 @@
import { markRaw } from 'vue'
import { defineAsyncComponent, markRaw } from 'vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
const WorkflowIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/workflow'))
)
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
return {
id: 'workflows',
icon: 'pi pi-folder-open',
icon: WorkflowIcon,
iconBadge: () => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
@@ -22,6 +26,7 @@ export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
},
title: 'sideToolbar.workflows',
tooltip: 'sideToolbar.workflows',
label: 'sideToolbar.labels.workflows',
component: markRaw(WorkflowsSidebarTab),
type: 'vue'
}

View File

@@ -21,6 +21,8 @@ import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -277,6 +279,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',
label: 'Fit view to selected nodes',
menubarLabel: 'Zoom to fit',
category: 'view-controls' as const,
function: () => {
if (app.canvas.empty) {
@@ -302,6 +305,7 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.Canvas.ToggleLinkVisibility',
icon: 'pi pi-eye',
label: 'Canvas Toggle Link Visibility',
menubarLabel: 'Node Links',
versionAdded: '1.3.6',
function: (() => {
@@ -323,12 +327,15 @@ export function useCoreCommands(): ComfyCommand[] {
)
}
}
})()
})(),
active: () =>
useSettingStore().get('Comfy.LinkRenderMode') !== LiteGraph.HIDDEN_LINK
},
{
id: 'Comfy.Canvas.ToggleMinimap',
icon: 'pi pi-map',
label: 'Canvas Toggle Minimap',
menubarLabel: 'Minimap',
versionAdded: '1.24.1',
function: async () => {
const settingStore = useSettingStore()
@@ -336,7 +343,8 @@ export function useCoreCommands(): ComfyCommand[] {
'Comfy.Minimap.Visible',
!settingStore.get('Comfy.Minimap.Visible')
)
}
},
active: () => useSettingStore().get('Comfy.Minimap.Visible')
},
{
id: 'Comfy.QueuePrompt',
@@ -540,21 +548,25 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Workspace.ToggleBottomPanel',
icon: 'pi pi-list',
label: 'Toggle Bottom Panel',
menubarLabel: 'Bottom Panel',
versionAdded: '1.3.22',
category: 'view-controls' as const,
function: () => {
bottomPanelStore.toggleBottomPanel()
}
},
active: () => bottomPanelStore.bottomPanelVisible
},
{
id: 'Workspace.ToggleFocusMode',
icon: 'pi pi-eye',
label: 'Toggle Focus Mode',
menubarLabel: 'Focus Mode',
versionAdded: '1.3.27',
category: 'view-controls' as const,
function: () => {
useWorkspaceStore().toggleFocusMode()
}
},
active: () => useWorkspaceStore().focusMode
},
{
id: 'Comfy.Graph.FitGroupToContents',
@@ -795,8 +807,53 @@ export function useCoreCommands(): ComfyCommand[] {
}
const { node } = res
canvas.select(node)
canvasStore.updateSelectedItems()
}
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const subgraphNode = app.canvas.selectedItems.values().next().value
useNodeOutputStore().revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline',
label: 'Manager',
function: () => {
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.ToggleHelpCenter',
icon: 'pi pi-question-circle',
label: 'Help Center',
function: () => {
useHelpCenterStore().toggle()
},
active: () => useHelpCenterStore().isVisible
},
{
id: 'Comfy.ToggleCanvasInfo',
icon: 'pi pi-info-circle',
label: 'Canvas Performance',
function: async () => {
const settingStore = useSettingStore()
const currentValue = settingStore.get('Comfy.Graph.CanvasInfo')
await settingStore.set('Comfy.Graph.CanvasInfo', !currentValue)
},
active: () => useSettingStore().get('Comfy.Graph.CanvasInfo')
},
{
id: 'Workspace.ToggleBottomPanel.Shortcuts',
icon: 'pi pi-key',

View File

@@ -2,6 +2,7 @@ import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
@@ -14,7 +15,7 @@ export function useDownload(url: string, fileName?: string) {
const fetchFileSize = async () => {
try {
const response = await fetch(url, { method: 'HEAD' })
const response = await fetchWithHeaders(url, { method: 'HEAD' })
if (!response.ok) throw new Error('Failed to fetch file size')
const size = response.headers.get('content-length')

View File

@@ -19,6 +19,7 @@ export const useLitegraphSettings = () => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
canvasStore.canvas.show_info = canvasInfoEnabled
canvasStore.canvas.draw(false, true)
}
})

View File

@@ -2,13 +2,14 @@ import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -16,6 +17,13 @@ interface GraphCallbacks {
onConnectionChange?: (node: LGraphNode) => void
}
export type MinimapOptionKey =
| 'Comfy.Minimap.NodeColors'
| 'Comfy.Minimap.ShowLinks'
| 'Comfy.Minimap.ShowGroups'
| 'Comfy.Minimap.RenderBypassState'
| 'Comfy.Minimap.RenderErrorState'
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
@@ -27,6 +35,27 @@ export function useMinimap() {
const visible = ref(true)
const nodeColors = computed(() =>
settingStore.get('Comfy.Minimap.NodeColors')
)
const showLinks = computed(() => settingStore.get('Comfy.Minimap.ShowLinks'))
const showGroups = computed(() =>
settingStore.get('Comfy.Minimap.ShowGroups')
)
const renderBypass = computed(() =>
settingStore.get('Comfy.Minimap.RenderBypassState')
)
const renderError = computed(() =>
settingStore.get('Comfy.Minimap.RenderErrorState')
)
const updateOption = async (key: MinimapOptionKey, value: boolean) => {
await settingStore.set(key, value)
needsFullRedraw.value = true
updateMinimap()
}
const initialized = ref(false)
const bounds = ref({
minX: 0,
@@ -63,10 +92,22 @@ export function useMinimap() {
const nodeColor = computed(
() => (isLightTheme.value ? '#3DA8E099' : '#0B8CE999') // lighter blue for light theme
)
const nodeColorDefault = computed(
() => (isLightTheme.value ? '#D9D9D9' : '#353535') // this is the default node color when using nodeColors setting
)
const linkColor = computed(
() => (isLightTheme.value ? '#FFB347' : '#F99614') // lighter orange for light theme
() => (isLightTheme.value ? '#616161' : '#B3B3B3') // lighter orange for light theme
)
const slotColor = computed(() => linkColor.value)
const groupColor = computed(() =>
isLightTheme.value ? '#A2D3EC' : '#1F547A'
)
const groupColorDefault = computed(
() => (isLightTheme.value ? '#283640' : '#B3C1CB') // this is the default group color when using nodeColors setting
)
const bypassColor = computed(() =>
isLightTheme.value ? '#DBDBDB' : '#4B184B'
)
const containerRect = ref({
left: 0,
@@ -116,6 +157,14 @@ export function useMinimap() {
borderRadius: '8px'
}))
const panelStyles = computed(() => ({
width: `210px`,
height: `${height}px`,
backgroundColor: isLightTheme.value ? '#FAF9F5' : '#15161C',
border: `1px solid ${isLightTheme.value ? '#ccc' : '#333'}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
@@ -189,6 +238,35 @@ export function useMinimap() {
return Math.min(scaleX, scaleY) * 0.9
}
const renderGroups = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._groups || g._groups.length === 0) return
for (const group of g._groups) {
const x = (group.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (group.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = group.size[0] * scale.value
const h = group.size[1] * scale.value
let color = groupColor.value
if (nodeColors.value) {
color = group.color ?? groupColorDefault.value
if (isLightTheme.value) {
color = adjustColor(color, { opacity: 0.5 })
}
}
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
@@ -203,9 +281,29 @@ export function useMinimap() {
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
let color = nodeColor.value
if (renderBypass.value && node.mode === LGraphEventMode.BYPASS) {
color = bypassColor.value
} else if (nodeColors.value) {
color = nodeColorDefault.value
if (node.bgcolor) {
color = isLightTheme.value
? adjustColor(node.bgcolor, { lightness: 0.5 })
: node.bgcolor
}
}
// Render solid node blocks
ctx.fillStyle = nodeColor.value
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
if (renderError.value && node.has_errors) {
ctx.strokeStyle = '#FF0000'
ctx.lineWidth = 0.3
ctx.strokeRect(x, y, w, h)
}
}
}
@@ -218,9 +316,9 @@ export function useMinimap() {
if (!g) return
ctx.strokeStyle = linkColor.value
ctx.lineWidth = 1.4
ctx.lineWidth = 0.3
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const slotRadius = Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
@@ -304,8 +402,15 @@ export function useMinimap() {
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
if (showGroups.value) {
renderGroups(ctx, offsetX, offsetY)
}
if (showLinks.value) {
renderConnections(ctx, offsetX, offsetY)
}
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
@@ -690,9 +795,16 @@ export function useMinimap() {
canvasRef,
containerStyles,
viewportStyles,
panelStyles,
width,
height,
nodeColors,
showLinks,
showGroups,
renderBypass,
renderError,
init,
destroy,
toggle,
@@ -701,6 +813,7 @@ export function useMinimap() {
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
setMinimapRef,
updateOption
}
}

View File

@@ -1,5 +1,5 @@
import { watchDebounced } from '@vueuse/core'
import { orderBy } from 'lodash'
import { orderBy } from 'es-toolkit/compat'
import { computed, ref, watch } from 'vue'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type {
@@ -160,12 +161,17 @@ export function useTemplateWorkflows() {
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
// Default templates provided by frontend are served as static files
const response = await fetchWithHeaders(
api.fileURL(`/templates/${id}.json`)
)
return await response.json()
} else {
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
// Custom node templates served via API
const response = await api.fetchApi(
`/workflow_templates/${sourceModule}/${id}.json`
)
return await response.json()
}
}

View File

@@ -1,3 +1,4 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, watch } from 'vue'
import { api } from '@/scripts/api'
@@ -88,6 +89,11 @@ export function useWorkflowPersistence() {
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Clean up event listener when component unmounts
tryOnScopeDispose(() => {
api.removeEventListener('graphChanged', persistCurrentWorkflow)
})
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'

View File

@@ -1,14 +1,16 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { createAxiosWithHeaders } from '@/services/networkClientAdapter'
const MAX_RETRIES = 5
const TIMEOUT = 4096
// Create axios client with header injection
const axiosClient = createAxiosWithHeaders()
export interface CacheEntry<T> {
data: T
timestamp?: number
@@ -58,7 +60,7 @@ const fetchData = async (
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
const res = await axiosClient.get(route, {
params: query_params,
signal: controller.signal,
timeout

View File

@@ -1,8 +1,9 @@
export const CORE_MENU_COMMANDS = [
[['Workflow'], ['Comfy.NewBlankWorkflow']],
[['Workflow'], ['Comfy.OpenWorkflow', 'Comfy.BrowseTemplates']],
[[], ['Comfy.NewBlankWorkflow']],
[[], []], // Separator after New
[['File'], ['Comfy.OpenWorkflow']],
[
['Workflow'],
['File'],
[
'Comfy.SaveWorkflow',
'Comfy.SaveWorkflowAs',
@@ -11,8 +12,6 @@ export const CORE_MENU_COMMANDS = [
]
],
[['Edit'], ['Comfy.Undo', 'Comfy.Redo']],
[['Edit'], ['Comfy.RefreshNodeDefinitions']],
[['Edit'], ['Comfy.ClearWorkflow']],
[['Edit'], ['Comfy.OpenClipspace']],
[
['Help'],

View File

@@ -300,7 +300,8 @@ export const CORE_SETTINGS: SettingParams[] = [
{ value: 'ja', text: '日本語' },
{ value: 'ko', text: '한국어' },
{ value: 'fr', text: 'Français' },
{ value: 'es', text: 'Español' }
{ value: 'es', text: 'Español' },
{ value: 'ar', text: 'عربي' }
],
defaultValue: () => navigator.language.split('-')[0] || 'en'
},
@@ -830,6 +831,41 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Minimap.NodeColors',
name: 'Display node with its original color on minimap',
type: 'hidden',
defaultValue: false,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowLinks',
name: 'Display links on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.ShowGroups',
name: 'Display node groups on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderBypassState',
name: 'Render bypass state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Minimap.RenderErrorState',
name: 'Render error state on minimap',
type: 'hidden',
defaultValue: true,
versionAdded: '1.26.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',

View File

@@ -1,438 +0,0 @@
export const CORE_TEMPLATES = [
{
moduleName: 'default',
title: 'Basics',
type: 'image',
templates: [
{
name: 'default',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images from text descriptions.'
},
{
name: 'image2image',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Transform existing images using text prompts.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/img2img/'
},
{
name: 'lora',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Apply LoRA models for specialized styles or subjects.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/lora/'
},
{
name: 'inpaint_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Edit specific parts of images seamlessly.',
thumbnailVariant: 'compareSlider',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/inpaint/'
},
{
name: 'inpain_model_outpainting',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Extend images beyond their original boundaries.',
thumbnailVariant: 'compareSlider',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/inpaint/#outpainting'
},
{
name: 'embedding_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Use textual inversion for consistent styles',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/textual_inversion_embeddings/'
},
{
name: 'gligen_textbox_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Specify the location and size of objects.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/gligen/'
},
{
name: 'lora_multiple',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Combine multiple LoRA models for unique results.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/lora/'
}
]
},
{
moduleName: 'default',
title: 'Flux',
type: 'image',
templates: [
{
name: 'flux_dev_checkpoint_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create images using Flux development models.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-dev-1'
},
{
name: 'flux_schnell',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images quickly with Flux Schnell.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#flux-schnell-1'
},
{
name: 'flux_fill_inpaint_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Fill in missing parts of images.',
thumbnailVariant: 'compareSlider',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#fill-inpainting-model'
},
{
name: 'flux_fill_outpaint_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Extend images using Flux outpainting.',
thumbnailVariant: 'compareSlider',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#fill-inpainting-model'
},
{
name: 'flux_canny_model_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images from edge detection.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#canny-and-depth'
},
{
name: 'flux_depth_lora_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create images with depth-aware LoRA.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#canny-and-depth'
},
{
name: 'flux_redux_model_example',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Transfer style from a reference image to guide image generation with Flux.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/flux/#redux'
}
]
},
{
moduleName: 'default',
title: 'ControlNet',
type: 'image',
templates: [
{
name: 'controlnet_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Control image generation with reference images.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/controlnet/'
},
{
name: '2_pass_pose_worship',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images from pose references.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/controlnet/#pose-controlnet'
},
{
name: 'depth_controlnet',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create images with depth-aware generation.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/controlnet/#t2i-adapter-vs-controlnets'
},
{
name: 'depth_t2i_adapter',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Quickly generate depth-aware images with a T2I adapter.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/controlnet/#t2i-adapter-vs-controlnets'
},
{
name: 'mixing_controlnets',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Combine multiple ControlNet models together.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/controlnet/#mixing-controlnets'
}
]
},
{
moduleName: 'default',
title: 'Upscaling',
type: 'image',
templates: [
{
name: 'hiresfix_latent_workflow',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Enhance image quality in latent space.',
thumbnailVariant: 'zoomHover',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/'
},
{
name: 'esrgan_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Use upscale models to enhance image quality.',
thumbnailVariant: 'zoomHover',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/upscale_models/'
},
{
name: 'hiresfix_esrgan_workflow',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Use upscale models during intermediate steps.',
thumbnailVariant: 'zoomHover',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/#non-latent-upscaling'
},
{
name: 'latent_upscale_different_prompt_model',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Upscale and change prompt across passes',
thumbnailVariant: 'zoomHover',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/2_pass_txt2img/#more-examples'
}
]
},
{
moduleName: 'default',
title: 'Video',
type: 'video',
templates: [
{
name: 'ltxv_text_to_video',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate videos from text descriptions.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/ltxv/#text-to-video'
},
{
name: 'ltxv_image_to_video',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Convert still images into videos.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/ltxv/#image-to-video'
},
{
name: 'mochi_text_to_video_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create videos with Mochi model.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/mochi/'
},
{
name: 'hunyuan_video_text_to_video',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate videos using Hunyuan model.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/'
},
{
name: 'image_to_video',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Transform images into animated videos.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/video/#image-to-video'
},
{
name: 'txt_to_image_to_video',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Generate images from text and then convert them into videos.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/video/#image-to-video'
}
]
},
{
moduleName: 'default',
title: 'SD3.5',
type: 'image',
templates: [
{
name: 'sd3.5_simple_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images with SD 3.5.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35'
},
{
name: 'sd3.5_large_canny_controlnet_example',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Use edge detection to guide image generation with SD 3.5.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets'
},
{
name: 'sd3.5_large_depth',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create depth-aware images with SD 3.5.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets'
},
{
name: 'sd3.5_large_blur',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Generate images from blurred reference images with SD 3.5.',
thumbnailVariant: 'hoverDissolve',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets'
}
]
},
{
moduleName: 'default',
title: 'SDXL',
type: 'image',
templates: [
{
name: 'sdxl_simple_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create high-quality images with SDXL.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/sdxl/'
},
{
name: 'sdxl_refiner_prompt_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Enhance SDXL outputs with refiners.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/sdxl/'
},
{
name: 'sdxl_revision_text_prompts',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Transfer concepts from reference images to guide image generation with SDXL.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sdxl/#revision'
},
{
name: 'sdxl_revision_zero_positive',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'Add text prompts alongside reference images to guide image generation with SDXL.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sdxl/#revision'
},
{
name: 'sdxlturbo_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate images in a single step with SDXL Turbo.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/'
}
]
},
{
moduleName: 'default',
title: 'Area Composition',
type: 'image',
templates: [
{
name: 'area_composition',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Control image composition with areas.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/area_composition/'
},
{
name: 'area_composition_reversed',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Reverse area composition workflow.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/area_composition/'
},
{
name: 'area_composition_square_area_for_subject',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Create consistent subject placement.',
tutorialUrl:
'https://comfyanonymous.github.io/ComfyUI_examples/area_composition/#increasing-consistency-of-images-with-area-composition'
}
]
},
{
moduleName: 'default',
title: '3D',
type: 'video',
templates: [
{
name: 'stable_zero123_example',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'Generate 3D views from single images.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/3d/'
}
]
},
{
moduleName: 'default',
title: 'Audio',
type: 'audio',
templates: [
{
name: 'stable_audio_example',
mediaType: 'audio',
mediaSubtype: 'mp3',
description: 'Generate audio from text descriptions.',
tutorialUrl: 'https://comfyanonymous.github.io/ComfyUI_examples/audio/'
}
]
}
]

View File

@@ -0,0 +1,133 @@
/**
* Example of how extensions can register header providers
* This file demonstrates the header registration API for extension developers
*/
import { headerRegistry } from '@/services/headerRegistry'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example 1: Simple static header provider
*/
class StaticHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
return {
'X-Extension-Name': 'my-extension',
'X-Extension-Version': '1.0.0'
}
}
}
/**
* Example 2: Dynamic header provider that adds headers based on the request
*/
class DynamicHeaderProvider implements IHeaderProvider {
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
const headers: HeaderMap = {}
// Add different headers based on the URL
if (context.url.includes('/api/')) {
headers['X-API-Version'] = 'v2'
}
// Add headers based on request method
if (context.method === 'POST' || context.method === 'PUT') {
headers['X-Request-ID'] = () => crypto.randomUUID()
}
// Add timestamp header
headers['X-Timestamp'] = () => new Date().toISOString()
return headers
}
}
/**
* Example 3: Auth token provider
*/
class AuthTokenProvider implements IHeaderProvider {
private getToken(): string | null {
// This could retrieve a token from storage, state, etc.
return localStorage.getItem('auth-token')
}
provideHeaders(_context: HeaderProviderContext): HeaderMap {
const token = this.getToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
}
/**
* Example of how to register providers in an extension
*/
export function setupHeaderProviders() {
// Register a simple static provider
const staticRegistration = headerRegistry.registerHeaderProvider(
new StaticHeaderProvider()
)
// Register a dynamic provider with higher priority
const dynamicRegistration = headerRegistry.registerHeaderProvider(
new DynamicHeaderProvider(),
{ priority: 10 }
)
// Register an auth provider that only applies to API endpoints
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 20, // Higher priority to override other auth headers
filter: (context) => context.url.includes('/api/')
}
)
// Return cleanup function for when extension is unloaded
return () => {
staticRegistration.dispose()
dynamicRegistration.dispose()
authRegistration.dispose()
}
}
/**
* Example of a provider that integrates with a cloud service
*/
export class CloudServiceHeaderProvider implements IHeaderProvider {
constructor(
private apiKey: string,
private workspaceId: string
) {}
async provideHeaders(context: HeaderProviderContext): Promise<HeaderMap> {
// Only add headers for requests to the cloud service
if (!context.url.includes('cloud.comfyui.com')) {
return {}
}
return {
'X-API-Key': this.apiKey,
'X-Workspace-ID': this.workspaceId,
'X-Client-Version': '1.0.0',
'X-Session-ID': async () => {
// Could fetch or generate session ID asynchronously
const sessionId = await this.getOrCreateSessionId()
return sessionId
}
}
}
private async getOrCreateSessionId(): Promise<string> {
// Simulate async session creation
return 'session-' + Date.now()
}
}

View File

@@ -0,0 +1,167 @@
import { headerRegistry } from '@/services/headerRegistry'
import type { ComfyExtension } from '@/types/comfy'
import type {
HeaderMap,
HeaderProviderContext,
IHeaderProvider
} from '@/types/headerTypes'
/**
* Example extension showing how to register header providers
* during the pre-init lifecycle hook.
*
* The pre-init hook is the earliest extension lifecycle hook,
* called before the canvas is created. This makes it perfect
* for registering cross-cutting concerns like header providers.
*/
// Example: Authentication token provider
class AuthTokenProvider implements IHeaderProvider {
async provideHeaders(_context: HeaderProviderContext): Promise<HeaderMap> {
// This could fetch tokens from a secure store, refresh them, etc.
const token = await this.getAuthToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
return {}
}
private async getAuthToken(): Promise<string | null> {
// Example: Get token from localStorage or a secure store
// In a real implementation, this might refresh tokens, handle expiration, etc.
return localStorage.getItem('auth_token')
}
}
// Example: API key provider for specific domains
class ApiKeyProvider implements IHeaderProvider {
private apiKeys: Record<string, string> = {
'api.example.com': 'example-api-key',
'api.another.com': 'another-api-key'
}
provideHeaders(context: HeaderProviderContext): HeaderMap {
const url = new URL(context.url)
const apiKey = this.apiKeys[url.hostname]
if (apiKey) {
return {
'X-API-Key': apiKey
}
}
return {}
}
}
// Example: Custom header provider for debugging
class DebugHeaderProvider implements IHeaderProvider {
provideHeaders(_context: HeaderProviderContext): HeaderMap {
if (process.env.NODE_ENV === 'development') {
return {
'X-Debug-Mode': 'true',
'X-Request-ID': crypto.randomUUID()
}
}
return {}
}
}
export const headerRegistrationExtension: ComfyExtension = {
name: 'HeaderRegistration',
/**
* Pre-init hook - called before canvas creation.
* This is the perfect place to register header providers.
*/
async preInit(_app) {
console.log(
'[HeaderRegistration] Registering header providers in pre-init hook'
)
// Register auth token provider with high priority
const authRegistration = headerRegistry.registerHeaderProvider(
new AuthTokenProvider(),
{
priority: 100
}
)
// Register API key provider
const apiKeyRegistration = headerRegistry.registerHeaderProvider(
new ApiKeyProvider(),
{
priority: 90
}
)
// Register debug header provider with lower priority
const debugRegistration = headerRegistry.registerHeaderProvider(
new DebugHeaderProvider(),
{
priority: 10
}
)
// Store registrations for potential cleanup later
// Extensions can store their data on the app instance
const extensionData = {
headerRegistrations: [
authRegistration,
apiKeyRegistration,
debugRegistration
]
}
// Store a reference on the extension itself for potential cleanup
;(headerRegistrationExtension as any).registrations =
extensionData.headerRegistrations
},
/**
* Standard init hook - called after canvas creation.
* At this point, header providers are already active.
*/
async init(_app) {
console.log(
'[HeaderRegistration] Headers are now being injected into all HTTP requests'
)
},
/**
* Setup hook - called after app is fully initialized.
* We could add UI elements here to manage headers.
*/
async setup(_app) {
// Example: Add a command to test header injection
const { useCommandStore } = await import('@/stores/commandStore')
useCommandStore().registerCommand({
id: 'header-registration.test',
icon: 'pi pi-globe',
label: 'Test Header Injection',
function: async () => {
try {
// Make a test request to see headers in action
const response = await fetch('/api/test')
console.log('[HeaderRegistration] Test request completed', {
status: response.status,
headers: response.headers
})
} catch (error) {
console.error('[HeaderRegistration] Test request failed', error)
}
}
})
}
}
// Extension usage:
// 1. Import this extension in your extension index
// 2. Register it with app.registerExtension(headerRegistrationExtension)
// 3. Header providers will be automatically registered before any network activity

View File

@@ -3,6 +3,7 @@ import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode
@@ -1172,8 +1173,7 @@ export class GroupNodeHandler {
// @ts-expect-error fixme ts strict error
getExtraMenuOptions?.apply(this, arguments)
// @ts-expect-error fixme ts strict error
let optionIndex = options.findIndex((o) => o.content === 'Outputs')
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
if (optionIndex === -1) optionIndex = options.length
else optionIndex++
options.splice(
@@ -1634,6 +1634,57 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node (Deprecated)`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1729,6 +1780,9 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -1,6 +1,7 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
class Load3dUtils {
@@ -9,7 +10,7 @@ class Load3dUtils {
prefix: string,
fileType: string = 'png'
) {
const blob = await fetch(imageData).then((r) => r.blob())
const blob = await fetchWithHeaders(imageData).then((r) => r.blob())
const name = `${prefix}_${Date.now()}.${fileType}`
const file = new File([blob], name, {
type: fileType === 'mp4' ? 'video/mp4' : 'image/png'

View File

@@ -4,6 +4,8 @@ import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { fetchWithHeaders } from '@/services/networkClientAdapter'
import { useToastStore } from '@/stores/toastStore'
export class ModelExporter {
@@ -36,7 +38,18 @@ export class ModelExporter {
desiredFilename: string
): Promise<void> {
try {
const response = await fetch(url)
// Check if this is a ComfyUI relative URL
const isComfyUrl = url.startsWith('/') || url.includes('/view?')
let response: Response
if (isComfyUrl) {
// Use ComfyUI API client for internal URLs
response = await fetchWithHeaders(api.apiURL(url))
} else {
// Use direct fetch for external URLs
response = await fetchWithHeaders(url)
}
const blob = await response.blob()
const link = document.createElement('a')

View File

@@ -1,48 +0,0 @@
/*
Taken from: https://github.com/gkjohnson/threejs-sandbox/tree/master/conditional-lines
under MIT license
*/
import { BufferAttribute, BufferGeometry, Vector3 } from 'three'
const vec = new Vector3()
export class OutsideEdgesGeometry extends BufferGeometry {
constructor(geometry) {
super()
const edgeInfo = {}
const index = geometry.index
const position = geometry.attributes.position
for (let i = 0, l = index.count; i < l; i += 3) {
const indices = [index.getX(i + 0), index.getX(i + 1), index.getX(i + 2)]
for (let j = 0; j < 3; j++) {
const index0 = indices[j]
const index1 = indices[(j + 1) % 3]
const hash = `${index0}_${index1}`
const reverseHash = `${index1}_${index0}`
if (reverseHash in edgeInfo) {
delete edgeInfo[reverseHash]
} else {
edgeInfo[hash] = [index0, index1]
}
}
}
const edgePositions = []
for (const key in edgeInfo) {
const [i0, i1] = edgeInfo[key]
vec.fromBufferAttribute(position, i0)
edgePositions.push(vec.x, vec.y, vec.z)
vec.fromBufferAttribute(position, i1)
edgePositions.push(vec.x, vec.y, vec.z)
}
this.setAttribute(
'position',
new BufferAttribute(new Float32Array(edgePositions), 3, false)
)
}
}

View File

@@ -1,5 +1,5 @@
import { debounce } from 'lodash'
import _ from 'lodash'
import { debounce } from 'es-toolkit/compat'
import _ from 'es-toolkit/compat'
import { t } from '@/i18n'

View File

@@ -1,5 +1,9 @@
import { createI18n } from 'vue-i18n'
import arCommands from './locales/ar/commands.json'
import ar from './locales/ar/main.json'
import arNodes from './locales/ar/nodeDefs.json'
import arSettings from './locales/ar/settings.json'
import enCommands from './locales/en/commands.json'
import en from './locales/en/main.json'
import enNodes from './locales/en/nodeDefs.json'
@@ -50,7 +54,8 @@ const messages = {
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
ko: buildLocale(ko, koNodes, koCommands, koSettings),
fr: buildLocale(fr, frNodes, frCommands, frSettings),
es: buildLocale(es, esNodes, esCommands, esSettings)
es: buildLocale(es, esNodes, esCommands, esSettings),
ar: buildLocale(ar, arNodes, arCommands, arSettings)
}
export const i18n = createI18n({

View File

@@ -495,6 +495,16 @@
padding-left: 12px;
}
.graphmenu-entry.danger,
.litemenu-entry.danger {
color: var(--error-text) !important;
}
.litegraph .litemenu-entry.danger:hover:not(.disabled) {
color: var(--error-text) !important;
opacity: 0.8;
}
.graphmenu-entry.disabled {
opacity: 0.3;
}

View File

@@ -43,6 +43,19 @@ export class CanvasPointer {
/** {@link maxClickDrift} squared. Used to calculate click drift without `sqrt`. */
static #maxClickDrift2 = this.#maxClickDrift ** 2
/** Assume that "wheel" events with both deltaX and deltaY less than this value are trackpad gestures. */
static trackpadThreshold = 60
/**
* The minimum time between "wheel" events to allow switching between trackpad
* and mouse modes.
*
* This prevents trackpad "flick" panning from registering as regular mouse wheel.
* After a flick gesture is complete, the automatic wheel events are sent with
* reduced frequency, but much higher deltaX and deltaY values.
*/
static trackpadMaxGap = 200
/** The element this PointerState should capture input against when dragging. */
element: Element
/** Pointer ID used by drag capture. */
@@ -77,6 +90,9 @@ export class CanvasPointer {
/** The last pointerup event for the primary button */
eUp?: CanvasPointerEvent
/** The last pointermove event that was treated as a trackpad gesture. */
lastTrackpadEvent?: WheelEvent
/**
* If set, as soon as the mouse moves outside the click drift threshold, this action is run once.
* @param pointer [DEPRECATED] This parameter will be removed in a future release.
@@ -257,6 +273,35 @@ export class CanvasPointer {
delete this.onDragStart
}
/**
* Checks if the given wheel event is part of a continued trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a continued trackpad gesture, otherwise `false`
*/
#isContinuationOfGesture(e: WheelEvent): boolean {
const { lastTrackpadEvent } = this
if (!lastTrackpadEvent) return false
return (
e.timeStamp - lastTrackpadEvent.timeStamp < CanvasPointer.trackpadMaxGap
)
}
/**
* Checks if the given wheel event is part of a trackpad gesture.
* @param e The wheel event to check
* @returns `true` if the event is part of a trackpad gesture, otherwise `false`
*/
isTrackpadGesture(e: WheelEvent): boolean {
if (this.#isContinuationOfGesture(e)) {
this.lastTrackpadEvent = e
return true
}
const threshold = CanvasPointer.trackpadThreshold
return Math.abs(e.deltaX) < threshold && Math.abs(e.deltaY) < threshold
}
/**
* Resets the state of this {@link CanvasPointer} instance.
*

View File

@@ -1,4 +1,4 @@
import { clamp } from 'lodash'
import { clamp } from 'es-toolkit/compat'
import type { Point, Rect } from './interfaces'
import { LGraphCanvas } from './litegraph'

View File

@@ -1,3 +1,5 @@
import { toString } from 'es-toolkit/compat'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -18,6 +20,7 @@ import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
import type {
DefaultConnectionColors,
Dictionary,
HasBoundingRect,
IContextMenuValue,
INodeInputSlot,
INodeOutputSlot,
@@ -26,7 +29,8 @@ import type {
MethodNames,
OptionalProps,
Point,
Positionable
Positionable,
Size
} from './interfaces'
import { LiteGraph, SubgraphNode } from './litegraph'
import {
@@ -34,7 +38,6 @@ import {
alignToContainer,
createBounds
} from './measure'
import { stringOrEmpty } from './strings'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1568,6 +1571,9 @@ export class LGraph
boundingRect
)
//Correct for title height. It's included in bounding box, but not _posSize
subgraphNode.pos[1] += LiteGraph.NODE_TITLE_HEIGHT / 2
// Add the subgraph node to the graph
this.add(subgraphNode)
@@ -1663,6 +1669,271 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
unpackSubgraph(subgraphNode: SubgraphNode) {
if (!(subgraphNode instanceof SubgraphNode))
throw new Error('Can only unpack Subgraph Nodes')
this.beforeChange()
//NOTE: Create bounds can not be called on positionables directly as the subgraph is not being displayed and boundingRect is not initialized.
//NOTE: NODE_TITLE_HEIGHT is explicitly excluded here
const positionables = [
...subgraphNode.subgraph.nodes,
...subgraphNode.subgraph.reroutes.values(),
...subgraphNode.subgraph.groups
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
return {
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
}
})
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
const center = [bounds[0] + bounds[2] / 2, bounds[1] + bounds[3] / 2]
const toSelect: Positionable[] = []
const offsetX = subgraphNode.pos[0] - center[0] + subgraphNode.size[0] / 2
const offsetY = subgraphNode.pos[1] - center[1] + subgraphNode.size[1] / 2
const movedNodes = multiClone(subgraphNode.subgraph.nodes)
const nodeIdMap = new Map<NodeId, NodeId>()
for (const n_info of movedNodes) {
const node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {
throw new Error('Node not found')
}
nodeIdMap.set(n_info.id, ++this.last_node_id)
node.id = this.last_node_id
n_info.id = this.last_node_id
this.add(node, true)
node.configure(n_info)
node.pos[0] += offsetX
node.pos[1] += offsetY
for (const input of node.inputs) {
input.link = null
}
for (const output of node.outputs) {
output.links = []
}
toSelect.push(node)
}
const groups = structuredClone(
[...subgraphNode.subgraph.groups].map((g) => g.serialize())
)
for (const g_info of groups) {
const group = new LGraphGroup(g_info.title, g_info.id)
this.add(group, true)
group.configure(g_info)
group.pos[0] += offsetX
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
tid: NodeId
tslot: number
id: LinkId
iparent?: RerouteId
eparent?: RerouteId
externalFirst: boolean
}[] = []
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
if (!outerLinkId) {
console.error('Missing Link ID when unpacking')
continue
}
const outerLink = this.links[outerLinkId]
link.origin_id = outerLink.origin_id
link.origin_slot = outerLink.origin_slot
externalParentId = outerLink.parentId
} else {
const origin_id = nodeIdMap.get(link.origin_id)
if (!origin_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: sublink.target_id,
tslot: sublink.target_slot,
id: link.id,
iparent: link.parentId,
eparent: sublink.parentId,
externalFirst: true
})
sublink.parentId = undefined
}
continue
} else {
const target_id = nodeIdMap.get(link.target_id)
if (!target_id) {
console.error('Missing Link ID when unpacking')
continue
}
link.target_id = target_id
}
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: link.target_id,
tslot: link.target_slot,
id: link.id,
iparent: link.parentId,
eparent: externalParentId,
externalFirst: false
})
}
this.remove(subgraphNode)
this.subgraphs.delete(subgraphNode.subgraph.id)
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const newLink of newLinks) {
let created: LLink | null | undefined
if (newLink.oid == SUBGRAPH_INPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.tid]
created = this.inputNode.slots[newLink.oslot].connect(
tnode.inputs[newLink.tslot],
tnode
)
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
}
const tnode = this._nodes_by_id[newLink.oid]
created = this.outputNode.slots[newLink.tslot].connect(
tnode.outputs[newLink.oslot],
tnode
)
} else {
created = this._nodes_by_id[newLink.oid].connect(
newLink.oslot,
this._nodes_by_id[newLink.tid],
newLink.tslot
)
}
if (!created) {
console.error('Failed to create link')
continue
}
//This is a little unwieldy since Map.has isn't a type guard
const linkIds = linkIdMap.get(newLink.id) ?? []
linkIds.push(created.id)
if (!linkIdMap.has(newLink.id)) {
linkIdMap.set(newLink.id, linkIds)
}
newLink.id = created.id
}
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedReroute = new Reroute(++this.state.lastRerouteId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
for (const newLink of newLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined = undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) throw new Error('Broken Id link when unpacking')
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) throw new Error('Broken Id link when unpacking')
parentId = oldReroute.parentId
}
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) throw new Error('Broken Id link when unpacking')
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
}
for (const nodeId of nodeIdMap.values()) {
const node = this._nodes_by_id[nodeId]
node._setConcreteSlots()
node.arrange()
}
this.canvasAction((c) => c.selectItems(toSelect))
this.afterChange()
}
/**
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
* Not intended to be run from subgraphs.
@@ -2027,7 +2298,7 @@ export class LGraph
if (url instanceof Blob || url instanceof File) {
const reader = new FileReader()
reader.addEventListener('load', (event) => {
const result = stringOrEmpty(event.target?.result)
const result = toString(event.target?.result)
const data = JSON.parse(result)
this.configure(data)
callback?.()
@@ -2331,6 +2602,9 @@ export class Subgraph
nodes: this.nodes.map((node) => node.serialize()),
groups: this.groups.map((group) => group.serialize()),
links: [...this.links.values()].map((x) => x.asSerialisable()),
reroutes: this.reroutes.size
? [...this.reroutes.values()].map((x) => x.asSerialisable())
: undefined,
extra: this.extra
}
}

View File

@@ -1,3 +1,5 @@
import { toString } from 'es-toolkit/compat'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { CanvasPointer } from './CanvasPointer'
@@ -55,7 +57,6 @@ import {
snapPoint
} from './measure'
import { NodeInputSlot } from './node/NodeInputSlot'
import { stringOrEmpty } from './strings'
import { Subgraph } from './subgraph/Subgraph'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
@@ -1244,7 +1245,7 @@ export class LGraphCanvas
value = LGraphCanvas.getPropertyPrintableValue(value, info.values)
// value could contain invalid html characters, clean that
value = LGraphCanvas.decodeHTML(stringOrEmpty(value))
value = LGraphCanvas.decodeHTML(toString(value))
entries.push({
content:
`<span class='property_name'>${info.label || i}</span>` +
@@ -3455,10 +3456,6 @@ export class LGraphCanvas
processMouseWheel(e: WheelEvent): void {
if (!this.graph || !this.allow_dragcanvas) return
// TODO: Mouse wheel zoom rewrite
// @ts-expect-error wheelDeltaY is non-standard property on WheelEvent
const delta = e.wheelDeltaY ?? e.detail * -60
this.adjustMouseEvent(e)
const pos: Point = [e.clientX, e.clientY]
@@ -3466,35 +3463,34 @@ export class LGraphCanvas
let { scale } = this.ds
if (
LiteGraph.canvasNavigationMode === 'legacy' ||
(LiteGraph.canvasNavigationMode === 'standard' && e.ctrlKey)
) {
if (delta > 0) {
scale *= this.zoom_speed
} else if (delta < 0) {
scale *= 1 / this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
} else if (
LiteGraph.macTrackpadGestures &&
(!LiteGraph.macGesturesRequireMac || navigator.userAgent.includes('Mac'))
) {
if (e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
if (e.deltaY > 0) {
scale *= 1 / this.zoom_speed
} else if (e.deltaY < 0) {
scale *= this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
} else if (e.ctrlKey) {
// Detect if this is a trackpad gesture or mouse wheel
const isTrackpad = this.pointer.isTrackpadGesture(e)
if (e.ctrlKey || LiteGraph.canvasNavigationMode === 'legacy') {
// Legacy mode or standard mode with ctrl - use wheel for zoom
if (isTrackpad) {
// Trackpad gesture - use smooth scaling
scale *= 1 + e.deltaY * (1 - this.zoom_speed) * 0.18
this.ds.changeScale(scale, [e.clientX, e.clientY], false)
} else if (e.shiftKey) {
this.ds.offset[0] -= e.deltaY * 1.18 * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * 1.18 * (1 / scale)
this.ds.offset[1] -= e.deltaY * 1.18 * (1 / scale)
// Mouse wheel - use stepped scaling
if (e.deltaY < 0) {
scale *= this.zoom_speed
} else if (e.deltaY > 0) {
scale *= 1 / this.zoom_speed
}
this.ds.changeScale(scale, [e.clientX, e.clientY])
}
} else {
// Standard mode without ctrl - use wheel / gestures to pan
// Trackpads and mice work on significantly different scales
const factor = isTrackpad ? 0.18 : 0.008_333
if (!isTrackpad && e.shiftKey && e.deltaX === 0) {
this.ds.offset[0] -= e.deltaY * (1 + factor) * (1 / scale)
} else {
this.ds.offset[0] -= e.deltaX * (1 + factor) * (1 / scale)
this.ds.offset[1] -= e.deltaY * (1 + factor) * (1 / scale)
}
}
@@ -6065,7 +6061,7 @@ export class LGraphCanvas
}
ctx.fillStyle = '#FFF'
ctx.fillText(
stringOrEmpty(node.order),
toString(node.order),
node.pos[0] + LiteGraph.NODE_TITLE_HEIGHT * -0.5,
node.pos[1] - 6
)
@@ -6233,9 +6229,17 @@ export class LGraphCanvas
break
}
case 'Delete':
graph.removeLink(segment.id)
case 'Delete': {
// segment can be a Reroute object, in which case segment.id is the reroute id
const linkId =
segment instanceof Reroute
? segment.linkIds.values().next().value
: segment.id
if (linkId !== undefined) {
graph.removeLink(linkId)
}
break
}
default:
}
}
@@ -8244,7 +8248,9 @@ export class LGraphCanvas
if (_slot.removable) {
menu_info.push(null)
menu_info.push(
_slot.locked ? 'Cannot remove' : { content: 'Remove Slot', slot }
_slot.locked
? 'Cannot remove'
: { content: 'Remove Slot', slot, className: 'danger' }
)
}

View File

@@ -284,6 +284,7 @@ export class LiteGraphGlobal {
]
/**
* @deprecated Removed; has no effect.
* If `true`, mouse wheel events will be interpreted as trackpad gestures.
* Tested on MacBook M4 Pro.
* @default false
@@ -292,6 +293,7 @@ export class LiteGraphGlobal {
macTrackpadGestures: boolean = false
/**
* @deprecated Removed; has no effect.
* If both this setting and {@link macTrackpadGestures} are `true`, trackpad gestures will
* only be enabled when the browser user agent includes "Mac".
* @default true

View File

@@ -135,6 +135,10 @@ export class FloatingRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return this.toType === 'output' && input.isValidTarget(this.fromSlot)
}
connectToInput(
node: LGraphNode,
input: INodeInputSlot,

View File

@@ -17,6 +17,8 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { EmptySubgraphInput } from '@/lib/litegraph/src/subgraph/EmptySubgraphInput'
import { EmptySubgraphOutput } from '@/lib/litegraph/src/subgraph/EmptySubgraphOutput'
import { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
@@ -638,20 +640,78 @@ export class LinkConnector {
if (connectingTo === 'input' && ioNode instanceof SubgraphOutputNode) {
const output = ioNode.getSlotInPosition(canvasX, canvasY)
if (!output) throw new Error('No output slot found for link.')
if (!output) {
this.dropOnNothing(event)
return
}
// Track the actual slot to use for all connections
let targetSlot = output
for (const link of renderLinks) {
link.connectToSubgraphOutput(output, this.events)
link.connectToSubgraphOutput(targetSlot, this.events)
// If we just connected to an EmptySubgraphOutput, check if we should reuse the slot
if (output instanceof EmptySubgraphOutput && ioNode.slots.length > 0) {
// Get the last created slot (newest one)
const createdSlot = ioNode.slots[ioNode.slots.length - 1]
// Only reuse the slot if the next link's type would be compatible
// Otherwise, keep using EmptySubgraphOutput to create a new slot
const nextLink = renderLinks[renderLinks.indexOf(link) + 1]
if (nextLink && link.fromSlot.type === nextLink.fromSlot.type) {
targetSlot = createdSlot
} else {
// Reset to EmptySubgraphOutput for different types
targetSlot = output
}
}
}
} else if (
connectingTo === 'output' &&
ioNode instanceof SubgraphInputNode
) {
const input = ioNode.getSlotInPosition(canvasX, canvasY)
if (!input) throw new Error('No input slot found for link.')
if (!input) {
this.dropOnNothing(event)
return
}
// Same logic for SubgraphInputNode if needed
let targetSlot = input
for (const link of renderLinks) {
link.connectToSubgraphInput(input, this.events)
// Validate the connection type before proceeding
if (
'canConnectToSubgraphInput' in link &&
!link.canConnectToSubgraphInput(targetSlot)
) {
console.warn(
'Invalid connection type',
link.fromSlot.type,
'->',
targetSlot.type
)
continue
}
link.connectToSubgraphInput(targetSlot, this.events)
// If we just connected to an EmptySubgraphInput, check if we should reuse the slot
if (input instanceof EmptySubgraphInput && ioNode.slots.length > 0) {
// Get the last created slot (newest one)
const createdSlot = ioNode.slots[ioNode.slots.length - 1]
// Only reuse the slot if the next link's type would be compatible
// Otherwise, keep using EmptySubgraphInput to create a new slot
const nextLink = renderLinks[renderLinks.indexOf(link) + 1]
if (nextLink && link.fromSlot.type === nextLink.fromSlot.type) {
targetSlot = createdSlot
} else {
// Reset to EmptySubgraphInput for different types
targetSlot = input
}
}
}
} else {
console.error(
@@ -895,6 +955,14 @@ export class LinkConnector {
)
}
isSubgraphInputValidDrop(input: SubgraphInput): boolean {
return this.renderLinks.some(
(link) =>
'canConnectToSubgraphInput' in link &&
link.canConnectToSubgraphInput(input)
)
}
/**
* Checks if a reroute is a valid drop target for any of the links being connected.
* @param reroute The reroute that would be dropped on.

View File

@@ -55,6 +55,10 @@ export class MovingOutputLink extends MovingLinkBase {
return reroute.origin_id !== this.outputNode.id
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToInput(): never {
throw new Error('MovingOutputLink cannot connect to an input.')
}

View File

@@ -58,6 +58,10 @@ export class ToOutputRenderLink implements RenderLink {
return true
}
canConnectToSubgraphInput(input: SubgraphInput): boolean {
return input.isValidTarget(this.fromSlot)
}
connectToOutput(
node: LGraphNode,
output: INodeOutputSlot,

View File

@@ -1,4 +1,4 @@
import { clamp } from 'lodash'
import { clamp } from 'es-toolkit/compat'
import type {
ReadOnlyRect,

View File

@@ -1,23 +1,5 @@
import type { ISlotType } from './litegraph'
/**
* Uses the standard String() function to coerce to string, unless the value is null or undefined - then null.
* @param value The value to convert
* @returns String(value) or null
*/
export function stringOrNull(value: unknown): string | null {
return value == null ? null : String(value)
}
/**
* Uses the standard String() function to coerce to string, unless the value is null or undefined - then an empty string
* @param value The value to convert
* @returns String(value) or ""
*/
export function stringOrEmpty(value: unknown): string {
return value == null ? '' : String(value)
}
export function parseSlotTypes(type: ISlotType): string[] {
return type == '' || type == '0'
? ['*']

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