Compare commits

...

35 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
10e5bc872e fix(manager): refactor PackTryUpdateButton to use Button component (#7638)
## Summary
- Refactors `PackTryUpdateButton` to use standard `Button` component
instead of deprecated `IconTextButton`
- Fixes broken import in `InfoPanelMultiItem.vue` (IconTextButton no
longer exists)
- Follows same pattern as `PackUninstallButton` and `PackInstallButton`

## Test plan
- [ ] Verify "Try Update" button appears and functions correctly for
nightly packs in the manager info panel
- [ ] Verify multi-select update button works in InfoPanelMultiItem
- [ ] Verify DotSpinner shows during update operation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7638-fix-manager-refactor-PackTryUpdateButton-to-use-Button-component-2ce6d73d3650816da271c2c0f442d59e)
by [Unito](https://www.unito.io)
2025-12-22 18:12:18 +00:00
Christian Byrne
9ec20f26d4 [backport core/1.35] Fix: the wrong selection under the hand mode (#7720)
## Summary

Backport of #7541 to core/1.35.

Fixed an inconsistency where nodes were resizable in Hand mode. Resizing
is now restricted to Selection mode only to match standard LiteGraph
behavior (Hand mode should only be for panning).

## Changes

- Added guard clause to prevent resize in Hand mode
(`shouldHandleNodePointerEvents` check)

## Original PR

https://github.com/Comfy-Org/ComfyUI_frontend/pull/7541

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7720-backport-core-1-35-Fix-the-wrong-selection-under-the-hand-mode-2d16d73d3650813bb00ff906db0440c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2025-12-22 11:10:38 -07:00
Comfy Org PR Bot
c6ae695799 [backport core/1.35] add pricing badge for Flux2Max node (#7721)
Backport of #7641 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7721-backport-core-1-35-add-pricing-badge-for-Flux2Max-node-2d16d73d36508108b1f9c09edc82e39c)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-22 11:09:51 -07:00
Comfy Org PR Bot
d73b35938b [backport core/1.35] Topbar: remove isDesktop gate for Custom Nodes Manager (#7719)
Backport of #7606 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7719-backport-core-1-35-Topbar-remove-isDesktop-gate-for-Custom-Nodes-Manager-2d16d73d365081d4abe0f344df112f65)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-22 11:09:12 -07:00
Comfy Org PR Bot
e7afb2b0d7 [backport core/1.35] fix: expand assets dropdown body to show entire "no results placeholder" (#7717)
Backport of #7586 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7717-backport-core-1-35-fix-expand-assets-dropdown-body-to-show-entire-no-results-placehol-2d16d73d365081ec9d87eee40876973a)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
2025-12-22 11:08:18 -07:00
Comfy Org PR Bot
9d721c96ec [backport core/1.35] fix(api-nodes-pricing): adjust prices for Tripo3D (#7714)
Backport of #6828 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7714-backport-core-1-35-fix-api-nodes-pricing-adjust-prices-for-Tripo3D-2d16d73d3650814caeadf715b877045a)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-22 10:32:43 -07:00
Comfy Org PR Bot
ea564170d1 [backport core/1.35] Fix: Extra Scrollbars in Media Assets Sidebar (#7711)
Backport of #7508 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7711-backport-core-1-35-Fix-Extra-Scrollbars-in-Media-Assets-Sidebar-2d16d73d365081c8acaddd46b1a05edc)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-22 10:15:42 -07:00
Comfy Org PR Bot
8080dc7cd7 [backport core/1.35] Nesting support for autogrow (#7709)
Backport of #7275 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7709-backport-core-1-35-Nesting-support-for-autogrow-2d16d73d3650813fa0bed22eed04ff1b)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-22 10:02:08 -07:00
Comfy Org PR Bot
ce18d4dfc6 [backport core/1.35] Add support for NO_TITLE in vue, disabling border (#7686)
Backport of #7589 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7686-backport-core-1-35-Add-support-for-NO_TITLE-in-vue-disabling-border-2cf6d73d3650815a88c7ce39cff56b90)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-20 14:55:44 -07:00
Comfy Org PR Bot
884f0a73c0 [backport core/1.35] Fix buttons displayed behind images in litegraph (#7684)
Backport of #7627 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7684-backport-core-1-35-Fix-buttons-displayed-behind-images-in-litegraph-2cf6d73d365081e28cdfc4b09d20cbba)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-20 14:29:15 -07:00
Comfy Org PR Bot
408b188a37 [backport core/1.35] Fix doubled control application (#7682)
Backport of #7550 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7682-backport-core-1-35-Fix-doubled-control-application-2cf6d73d365081908428efca082dfd98)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-20 14:14:15 -07:00
Comfy Org PR Bot
9a9239c161 [backport core/1.35] Make node inputs reactive in vue (#7680)
Backport of #7546 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7680-backport-core-1-35-Make-node-inputs-reactive-in-vue-2cf6d73d3650818d96b0d492731ffee4)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-20 14:02:30 -07:00
Christian Byrne
a8d0cfe058 [backport core/1.35] Do not delay fit to view on graph restore (#7678)
Backport of #7645 to core/1.35

## Original PR Summary
Fixes a bug where swapping to a different workflow from the inside of a
subgraph would cause nodes to be in an incorrect position after swapping
back in vue mode.

This is done by not delaying the fitView by a frame (which causes it to
occur after the graph is no longer in the configuring state).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7678-backport-core-1-35-Do-not-delay-fit-to-view-on-graph-restore-2cf6d73d36508101aea2e54c5388a32f)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-20 13:37:34 -07:00
Comfy Org PR Bot
b7b4608e7b [backport core/1.35] Fix widget reactivity (#7675)
Backport of #7539 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7675-backport-core-1-35-Fix-widget-reactivity-2cf6d73d365081bb8521d4826d16debb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-20 13:35:28 -07:00
Comfy Org PR Bot
d2281f64c9 [backport core/1.35] Feat: Fixed option for control after/before generate (#7662)
Backport of #7517 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7662-backport-core-1-35-Feat-Fixed-option-for-control-after-before-generate-2cf6d73d365081cf8b07d52cbe8dba72)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-20 13:21:14 -07:00
Christian Byrne
463a04eb5a [backport core/1.35] Tests: Golden Updates (#7674)
Backport of #7624

Cherry-picked merge commit 6244cf1008 to
core/1.35.

**Conflicts resolved:**
-
`browser_tests/tests/viewport.spec.ts-snapshots/viewport-fits-when-saved-offscreen-chromium-linux.png`
- modify/delete conflict, kept PR version (file was deleted in target,
modified in PR)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7674-backport-core-1-35-Tests-Golden-Updates-2cf6d73d3650814ba547c6b35155ee67)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-20 13:20:48 -07:00
Christian Byrne
dfc58c0c1d [backport core/1.35] Chore: Update several Developer Dependencies (#7672)
## Summary

Backport of #7590 to core/1.35.

Includes fixes for unused refs in Vue components. Major version updates
(nx, vite, @types/node) kept at target branch versions for stability.

Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7590

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7672-backport-core-1-35-Chore-Update-several-Developer-Dependencies-2cf6d73d365081389334c8b02edb7581)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-20 12:54:56 -07:00
Christian Byrne
59966243fc [backport core/1.35] Deps: Update Playwright (#7670)
## Summary

Backport of #7623 to core/1.35.

Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7623

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7670-backport-core-1-35-Deps-Update-Playwright-2cf6d73d365081a1b82dd84f230e9a10)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2025-12-20 12:32:25 -07:00
Comfy Org PR Bot
1393e438d3 [backport core/1.35] Support fixed seed in vue (#7656)
Backport of #7510 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7656-backport-core-1-35-Support-fixed-seed-in-vue-2cf6d73d36508160b8daf48fc09ac141)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-19 22:28:12 -07:00
Comfy Org PR Bot
574e5bd991 [backport core/1.35] Prevent sidebar tool buttons from flashing during collapse (#7654)
Backport of #7652 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7654-backport-core-1-35-Prevent-sidebar-tool-buttons-from-flashing-during-collapse-2cf6d73d365081b7aa45d585e14e6b69)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-19 22:27:52 -07:00
Comfy Org PR Bot
903c830dd0 [backport core/1.35] fix: node preview Vue mode sizing in manager panel (#7640)
Backport of #7611 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7640-backport-core-1-35-fix-node-preview-Vue-mode-sizing-in-manager-panel-2ce6d73d365081759ef5c8e05ff92553)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-19 17:21:48 -07:00
Comfy Org PR Bot
8c651f22a0 [backport core/1.35] fix: prevent custom context menu when editing text (#7636)
Backport of #7633 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7636-backport-core-1-35-fix-prevent-custom-context-menu-when-editing-text-2ce6d73d3650816f9fb0de2a6cb50cdb)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-19 17:21:27 -07:00
Comfy Org PR Bot
36027a858f [backport core/1.35] feat(manager): add Try Update button for nightly packs (#7635)
Backport of #7610 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7635-backport-core-1-35-feat-manager-add-Try-Update-button-for-nightly-packs-2ce6d73d36508135a070e17689cee5e7)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-12-19 17:21:11 -07:00
Comfy Org PR Bot
a26fc1cd8f [backport core/1.35] Fix promoted assets not being assets in vue (#7604)
Backport of #7576 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7604-backport-core-1-35-Fix-promoted-assets-not-being-assets-in-vue-2cd6d73d36508151b084fbdd386f8071)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-12-18 19:21:28 -07:00
Comfy Org PR Bot
0371dca1b6 [backport core/1.35] fix: right side panel visual indicators don't update for color picker… (#7607)
Backport of #7489 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7607-backport-core-1-35-fix-right-side-panel-visual-indicators-don-t-update-for-color-picke-2cd6d73d3650811a8916e7c9e22c0a20)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
2025-12-18 19:21:20 -07:00
Comfy Org PR Bot
1c914c7dff [backport core/1.35] feat: improve vue node video preview loading and a11y (#7561)
Backport of #7558 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7561-backport-core-1-35-feat-improve-vue-node-video-preview-loading-and-a11y-2cb6d73d3650813284bdfdef8f3aef21)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-17 19:50:07 -07:00
Comfy Org PR Bot
a68c169179 [backport core/1.35] fix: "convert to subgraph" not shown in context menu if subgraph inside the selection context (#7492)
Backport of #7470 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7492-backport-core-1-35-fix-convert-to-subgraph-not-shown-in-context-menu-if-subgraph-ins-2ca6d73d365081f2a76de1b4fee31744)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-15 17:23:15 -07:00
Comfy Org PR Bot
0fed12a62d [backport core/1.35] fix: collapsed nodes getting extra height based on contents (#7530)
Backport of #7490 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7530-backport-core-1-35-fix-collapsed-nodes-getting-extra-height-based-on-contents-2ca6d73d3650810ebc93e4b39710d2f3)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
2025-12-15 17:22:49 -07:00
Comfy Org PR Bot
8238afacf2 [backport core/1.35] fix: prevent middle mouse button from triggering node resize in vueNodes mode (#7529)
Backport of #7511 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7529-backport-core-1-35-fix-prevent-middle-mouse-button-from-triggering-node-resize-in-vueN-2ca6d73d3650816eac41e579c41917b2)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-15 17:21:29 -07:00
Comfy Org PR Bot
14c12350eb 1.35.8 (#7519)
Patch version increment to 1.35.8

**Base branch:** `core/1.35`

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

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2025-12-15 14:31:36 -07:00
Comfy Org PR Bot
292fd5eb68 [backport core/1.35] Topbar: add Custom Nodes Manager button (#7496)
Backport of #7400 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7496-backport-core-1-35-Topbar-add-Custom-Nodes-Manager-button-2ca6d73d3650813093b5d466f80c631e)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-15 12:32:53 -08:00
Comfy Org PR Bot
47aab2c8e1 [backport core/1.35] fix: prevent unrelated groups from moving when dragging nodes in vueNodes mode (#7485)
Backport of #7473 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7485-backport-core-1-35-fix-prevent-unrelated-groups-from-moving-when-dragging-nodes-in-vue-2ca6d73d365081e3b255e832088a90c9)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-12-14 19:29:15 -08:00
Comfy Org PR Bot
c5312d6963 [backport core/1.35] feat(server-config): add legacy manager UI toggle (#7482)
Backport of #7478 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7482-backport-core-1-35-feat-server-config-add-legacy-manager-UI-toggle-2ca6d73d365081c8b614dee970faf9ee)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-12-14 19:00:18 -08:00
Comfy Org PR Bot
43e11c6873 [backport core/1.35] fix: inner groups being moved double when moving outer group (in vue mode) (#7480)
Backport of #7447 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7480-backport-core-1-35-fix-inner-groups-being-moved-double-when-moving-outer-group-in-vue-2ca6d73d365081b7a0ddf63abc119b1f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-14 19:00:03 -08:00
Comfy Org PR Bot
4a041bead2 [backport core/1.35] remove contentype badge from media assets card (#7471)
Backport of #7440 to `core/1.35`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7471-backport-core-1-35-remove-contentype-badge-from-media-assets-card-2c96d73d365081359610dd7189f2394b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-12-14 18:35:43 -08:00
101 changed files with 1959 additions and 1681 deletions

1
.npmrc
View File

@@ -1,2 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

@@ -0,0 +1,92 @@
{
"id": "2ba0b800-2f13-4f21-b8d6-c6cdb0152cae",
"revision": 0,
"last_node_id": 17,
"last_link_id": 9,
"nodes": [
{
"id": 17,
"type": "VAEDecode",
"pos": [
318.8446183157076,
355.3961392345528
],
"size": [
225,
102
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
}
],
"links": [],
"groups": [
{
"id": 4,
"title": "Outer Group",
"bounding": [
-46.25245366331014,
-150.82497138023245,
1034.4034361963616,
1007.338460439933
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Inner Group",
"bounding": [
80.96059074101554,
28.123757436778178,
718.286373661183,
691.2397164539732
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.7121393732101533,
"offset": [
289.18242848011835,
367.0747755524199
]
},
"frontendVersion": "1.35.5",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}

View File

@@ -1653,6 +1653,55 @@ export class ComfyPage {
}, focusMode)
await this.nextFrame()
}
/**
* Get the position of a group by title.
* @param title The title of the group to find
* @returns The group's canvas position
* @throws Error if group not found
*/
async getGroupPosition(title: string): Promise<Position> {
const pos = await this.page.evaluate((title) => {
const groups = window['app'].graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
return { x: group.pos[0], y: group.pos[1] }
}, title)
if (!pos) throw new Error(`Group "${title}" not found`)
return pos
}
/**
* Drag a group by its title.
* @param options.name The title of the group to drag
* @param options.deltaX Horizontal drag distance in screen pixels
* @param options.deltaY Vertical drag distance in screen pixels
*/
async dragGroup(options: {
name: string
deltaX: number
deltaY: number
}): Promise<void> {
const { name, deltaX, deltaY } = options
const screenPos = await this.page.evaluate((title) => {
const app = window['app']
const groups = app.graph.groups
const group = groups.find((g: { title: string }) => g.title === title)
if (!group) return null
// Position in the title area of the group
const clientPos = app.canvasPosToClientPos([
group.pos[0] + 50,
group.pos[1] + 15
])
return { x: clientPos[0], y: clientPos[1] }
}, name)
if (!screenPos) throw new Error(`Group "${name}" not found`)
await this.dragAndDrop(screenPos, {
x: screenPos.x + deltaX,
y: screenPos.y + deltaY
})
}
}
export const testComfySnapToGridGridSize = 50

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -32,4 +32,42 @@ test.describe('Vue Node Groups', () => {
'vue-groups-fit-to-contents.png'
)
})
test('should move nested groups together when dragging outer group', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('groups/nested-groups-1-inner-node')
// Get initial positions with null guards
const outerInitial = await comfyPage.getGroupPosition('Outer Group')
const innerInitial = await comfyPage.getGroupPosition('Inner Group')
const initialOffsetX = innerInitial.x - outerInitial.x
const initialOffsetY = innerInitial.y - outerInitial.y
// Drag the outer group
const dragDelta = { x: 100, y: 80 }
await comfyPage.dragGroup({
name: 'Outer Group',
deltaX: dragDelta.x,
deltaY: dragDelta.y
})
// Use retrying assertion to wait for positions to update
await expect(async () => {
const outerFinal = await comfyPage.getGroupPosition('Outer Group')
const innerFinal = await comfyPage.getGroupPosition('Inner Group')
const finalOffsetX = innerFinal.x - outerFinal.x
const finalOffsetY = innerFinal.y - outerFinal.y
// Both groups should have moved
expect(outerFinal.x).not.toBe(outerInitial.x)
expect(innerFinal.x).not.toBe(innerInitial.x)
// The relative offset should be maintained (inner group moved with outer)
expect(finalOffsetX).toBeCloseTo(initialOffsetX, 0)
expect(finalOffsetY).toBeCloseTo(initialOffsetY, 0)
}).toPass({ timeout: 5000 })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -205,6 +205,32 @@ test.describe('Image widget', () => {
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
test('Displays buttons when viewing single image of batch', async ({
comfyPage
}) => {
const [x, y] = await comfyPage.page.evaluate(() => {
const src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E"
const image1 = new Image()
image1.src = src
const image2 = new Image()
image2.src = src
const targetNode = graph.nodes[6]
targetNode.imgs = [image1, image2]
targetNode.imageIndex = 1
app.canvas.setDirty(true)
const x = targetNode.pos[0] + targetNode.size[0] - 41
const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30
return app.canvasPosToClientPos([x, y])
})
const clip = { x, y, width: 35, height: 35 }
await expect(comfyPage.page).toHaveScreenshot(
'image_preview_close_button.png',
{ clip }
)
})
})
test.describe('Animated image widget', () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.7",
"version": "1.35.8",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -19,6 +19,7 @@
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
"dev": "nx serve",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",

633
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ catalog:
'@nx/storybook': 21.4.1
'@nx/vite': 21.4.1
'@pinia/testing': ^0.1.5
'@playwright/test': ^1.52.0
'@playwright/test': ^1.57.0
'@prettier/plugin-oxc': ^0.1.3
'@primeuix/forms': 0.0.2
'@primeuix/styled': 0.3.2
@@ -60,11 +60,11 @@ catalog:
firebase: ^11.6.0
globals: ^15.9.0
happy-dom: ^15.11.0
husky: ^9.0.11
jiti: 2.4.2
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.5.2
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 21.4.1

View File

@@ -10,48 +10,66 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div class="flex items-center gap-2">
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
<IconButton
v-tooltip.bottom="customNodesManagerTooltipConfig"
type="transparent"
size="sm"
class="text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('menu.customNodesManager')"
@click="openCustomNodeManager"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
<i class="icon-[lucide--puzzle] size-4" />
</IconButton>
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="relative mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
<IconButton
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="transparent"
size="sm"
class="mr-2 text-base-foreground transition-colors duration-200 ease-in-out bg-secondary-background hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</IconButton>
</div>
</div>
<QueueProgressOverlay
v-model:expanded="isQueueOverlayExpanded"
@@ -74,18 +92,23 @@ import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const isTopMenuHovered = ref(false)
@@ -93,6 +116,9 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
@@ -112,10 +138,20 @@ onMounted(() => {
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>
.actionbar-container {
background-color: var(--comfy-menu-bg);
const openCustomNodeManager = async () => {
try {
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
console.error(error)
console.error(toastError)
}
}
}
</style>
</script>

View File

@@ -21,6 +21,7 @@
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture
@pointermove.stop.capture
/>

View File

@@ -12,7 +12,6 @@
/>
<img
v-if="cachedSrc"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
@@ -61,7 +60,6 @@ const {
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)

View File

@@ -48,7 +48,6 @@
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
>
<InputNumber
ref="zoomInput"
:default-value="canvasStore.appScalePercentage"
:min="1"
:max="1000"
@@ -130,7 +129,6 @@ const zoomOutCommandText = computed(() =>
const zoomToFitCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
)
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
const zoomInputContainer = ref<HTMLDivElement | null>(null)
watch(

View File

@@ -9,7 +9,6 @@
>
<Load3DScene
v-if="node"
ref="load3DSceneRef"
:initialize-load3d="initializeLoad3d"
:cleanup="cleanup"
:loading="loading"
@@ -100,8 +99,6 @@ if (isComponentWidget(props.widget)) {
})
}
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const {
// configs
sceneConfig,

View File

@@ -14,11 +14,7 @@
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
>
<LoadingOverlay
ref="loadingOverlayRef"
:loading="loading"
:loading-message="loadingMessage"
/>
<LoadingOverlay :loading="loading" :loading-message="loadingMessage" />
<div
v-if="!isPreview && isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -48,7 +44,6 @@ const props = defineProps<{
}>()
const container = ref<HTMLElement | null>(null)
const loadingOverlayRef = ref<InstanceType<typeof LoadingOverlay> | null>(null)
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({

View File

@@ -6,7 +6,7 @@
@mouseenter="viewer.handleMouseEnter"
@mouseleave="viewer.handleMouseLeave"
>
<div ref="mainContentRef" class="relative flex-1">
<div class="relative flex-1">
<div
ref="containerRef"
class="absolute h-full w-full"
@@ -105,7 +105,6 @@ const props = defineProps<{
const viewerContentRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>()
const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)

View File

@@ -2,7 +2,11 @@
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<LGraphNodePreview
v-if="shouldRenderVueNodes"
:node-def="nodeDef"
:position="position"
/>
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
@@ -92,8 +96,9 @@ import { useWidgetStore } from '@/stores/widgetStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const { nodeDef } = defineProps<{
const { nodeDef, position = 'absolute' } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
}>()
const { shouldRenderVueNodes } = useVueFeatureFlags()

View File

@@ -78,7 +78,7 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { computed, shallowRef, triggerRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -90,10 +90,23 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
const { nodes = [] } = defineProps<{
const props = defineProps<{
nodes?: LGraphNode[]
}>()
/**
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
const targetNodes = shallowRef<LGraphNode[]>([])
watchEffect(() => {
if (props.nodes) {
targetNodes.value = props.nodes
} else {
targetNodes.value = []
}
})
const { t } = useI18n()
const canvasStore = useCanvasStore()
@@ -103,24 +116,33 @@ const isLightTheme = computed(
)
const nodeState = computed({
get(): LGraphNode['mode'] | null {
if (!nodes.length) return null
if (nodes.length === 1) {
return nodes[0].mode
}
get() {
let mode: LGraphNode['mode'] | null = null
const nodes = targetNodes.value
if (nodes.length === 0) return null
// For multiple nodes, if all nodes have the same mode, return that mode, otherwise return null
const mode: LGraphNode['mode'] = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
return null
if (nodes.length > 1) {
mode = nodes[0].mode
if (!nodes.every((node) => node.mode === mode)) {
mode = null
}
} else {
mode = nodes[0].mode
}
return mode
},
set(value: LGraphNode['mode']) {
nodes.forEach((node) => {
targetNodes.value.forEach((node) => {
node.mode = value
})
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -128,10 +150,15 @@ const nodeState = computed({
// Pinned state
const isPinned = computed<boolean>({
get() {
return nodes.some((node) => node.pinned)
return targetNodes.value.some((node) => node.pinned)
},
set(value) {
nodes.forEach((node) => node.pin(value))
targetNodes.value.forEach((node) => node.pin(value))
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})
@@ -175,8 +202,10 @@ const colorOptions: NodeColorOption[] = [
const nodeColor = computed<NodeColorOption['name'] | null>({
get() {
if (nodes.length === 0) return null
const theColorOptions = nodes.map((item) => item.getColorOption())
if (targetNodes.value.length === 0) return null
const theColorOptions = targetNodes.value.map((item) =>
item.getColorOption()
)
let colorOption: ColorOption | null | false = theColorOptions[0]
if (!theColorOptions.every((option) => option === colorOption)) {
@@ -202,9 +231,14 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
? null
: LGraphCanvas.node_colors[colorName]
for (const item of nodes) {
for (const item of targetNodes.value) {
item.setColorOption(canvasColorOption)
}
/*
* This is not random writing. It is very important.
* Otherwise, the UI cannot be updated correctly.
*/
triggerRef(targetNodes)
canvasStore.canvas?.setDirty(true, true)
}
})

View File

@@ -1,6 +1,5 @@
<template>
<div
ref="menuButtonRef"
v-tooltip="{
value: t('sideToolbar.labels.menu'),
showDelay: 300,
@@ -137,7 +136,6 @@ const settingStore = useSettingStore()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const menuButtonRef = ref<HTMLElement | null>(null)
const nodes2Enabled = computed({
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,

View File

@@ -11,7 +11,6 @@
}"
>
<div
ref="contentMeasureRef"
:class="
isOverflowing
? 'side-tool-bar-container overflow-y-auto'
@@ -80,7 +79,6 @@ const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const sideToolbarRef = ref<HTMLElement>()
const contentMeasureRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()

View File

@@ -56,9 +56,9 @@
class="pb-1 px-2 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
</template>
<template #body>
<Divider type="dashed" class="m-2" />
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>

View File

@@ -15,7 +15,7 @@
</template>
<template #end>
<div
class="touch:w-auto touch:opacity-100 flex flex-row transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
class="touch:w-auto touch:opacity-100 flex flex-row overflow-hidden transition-all duration-200 motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100"
>
<slot name="tool-buttons" />
</div>

View File

@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -30,8 +31,9 @@ import type {
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '../../lib/litegraph/src/litegraph'
import { NodeSlotType } from '../../lib/litegraph/src/types/globalEnums'
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
export interface WidgetSlotMetadata {
index: number
@@ -42,37 +44,39 @@ export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
label?: string
options?: IWidgetOptions<unknown>
borderStyle?: string
callback?: ((value: unknown) => void) | undefined
controlWidget?: SafeControlWidget
isDOMWidget?: boolean
label?: string
nodeType?: string
options?: IWidgetOptions<unknown>
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
controlWidget?: SafeControlWidget
borderStyle?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
title: string
type: string
mode: number
selected: boolean
executing: boolean
title: string
type: string
apiNode?: boolean
badges?: (LGraphBadge | (() => LGraphBadge))[]
subgraphId?: string | null
widgets?: SafeWidgetData[]
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
hasErrors?: boolean
bgcolor?: string
color?: string
flags?: {
collapsed?: boolean
pinned?: boolean
}
color?: string
bgcolor?: string
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
shape?: number
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
@@ -96,6 +100,11 @@ function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
function getNodeType(node: LGraphNode, widget: IBaseWidget) {
if (!node.isSubgraphNode() || !isProxyWidget(widget)) return undefined
const subNode = node.subgraph.getNodeById(widget._overlay.nodeId)
return subNode?.type
}
export function safeWidgetMapper(
node: LGraphNode,
@@ -131,12 +140,13 @@ export function safeWidgetMapper(
value: value,
borderStyle,
callback: widget.callback,
controlWidget: getControlWidget(widget),
isDOMWidget: isDOMWidget(widget),
label: widget.label,
nodeType: getNodeType(node, widget),
options: widget.options,
spec,
slotMetadata: slotInfo,
controlWidget: getControlWidget(widget)
slotMetadata: slotInfo
}
} catch (error) {
return {
@@ -218,6 +228,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
@@ -245,6 +264,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
@@ -252,7 +272,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,

View File

@@ -173,7 +173,12 @@ export function useMoreOptionsMenu() {
}
// Section 5: Subgraph operations
options.push(...getSubgraphOptions(hasSubgraphsSelected))
options.push(
...getSubgraphOptions({
hasSubgraphs: hasSubgraphsSelected,
hasMultipleSelection: hasMultipleNodes.value
})
)
// Section 6: Multiple nodes operations
if (hasMultipleNodes.value) {

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
te: vi.fn(),
d: vi.fn()
}
}))
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
useSelectionOperations: () => ({
copySelection: vi.fn(),
duplicateSelection: vi.fn(),
deleteSelection: vi.fn(),
renameSelection: vi.fn()
})
}))
vi.mock('@/composables/graph/useNodeArrangement', () => ({
useNodeArrangement: () => ({
alignOptions: [{ localizedName: 'align-left', icon: 'align-left' }],
distributeOptions: [{ localizedName: 'distribute', icon: 'distribute' }],
applyAlign: vi.fn(),
applyDistribute: vi.fn()
})
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
})
}))
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns only convert option when no subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: false,
hasMultipleSelection: true
})
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: true
})
const labels = options.map((option) => option.label)
expect(labels).toContain('contextMenu.Convert to Subgraph')
expect(labels).toContain('contextMenu.Add Subgraph to Library')
expect(labels).toContain('contextMenu.Unpack Subgraph')
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {
const { getSubgraphOptions } = useSelectionMenuOptions()
const options = getSubgraphOptions({
hasSubgraphs: true,
hasMultipleSelection: false
})
const labels = options.map((option) => option.label)
expect(labels).not.toContain('contextMenu.Convert to Subgraph')
expect(labels).toEqual([
'contextMenu.Add Subgraph to Library',
'contextMenu.Unpack Subgraph'
])
})
})

View File

@@ -63,9 +63,29 @@ export function useSelectionMenuOptions() {
}
]
const getSubgraphOptions = (hasSubgraphs: boolean): MenuOption[] => {
const getSubgraphOptions = ({
hasSubgraphs,
hasMultipleSelection
}: {
hasSubgraphs: boolean
hasMultipleSelection: boolean
}): MenuOption[] => {
const convertOption: MenuOption = {
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
const options: MenuOption[] = []
const showConvertOption = !hasSubgraphs || hasMultipleSelection
if (showConvertOption) {
options.push(convertOption)
}
if (hasSubgraphs) {
return [
options.push(
{
label: t('contextMenu.Add Subgraph to Library'),
icon: 'icon-[lucide--folder-plus]',
@@ -76,17 +96,10 @@ export function useSelectionMenuOptions() {
icon: 'icon-[lucide--expand]',
action: unpackSubgraph
}
]
} else {
return [
{
label: t('contextMenu.Convert to Subgraph'),
icon: 'icon-[lucide--shrink]',
action: convertToSubgraph,
badge: BadgeVariant.NEW
}
]
)
}
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {

View File

@@ -329,6 +329,123 @@ const sora2PricingCalculator: PricingFunction = (node: LGraphNode): string => {
return formatRunPrice(perSec, duration)
}
/**
* Pricing for Tripo 3D generation nodes (Text / Image / Multiview)
* based on Tripo credits:
*
* Turbo / V3 / V2.5 / V2.0:
* Text -> 10 (no texture) / 20 (standard texture)
* Image -> 20 (no texture) / 30 (standard texture)
* Multiview -> 20 (no texture) / 30 (standard texture)
*
* V1.4:
* Text -> 20
* Image -> 30
* (Multiview treated same as Image if used)
*
* Advanced extras (added on top of generation credits):
* quad -> +5 credits
* style -> +5 credits (if style != "None")
* HD texture -> +10 credits (texture_quality = "detailed")
* detailed geometry -> +20 credits (geometry_quality = "detailed")
*
* 1 credit = $0.01
*/
const calculateTripo3DGenerationPrice = (
node: LGraphNode,
task: 'text' | 'image' | 'multiview'
): string => {
const getWidget = (name: string): IComboWidget | undefined =>
node.widgets?.find((w) => w.name === name) as IComboWidget | undefined
const getString = (name: string, defaultValue: string): string => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
return String(widget.value)
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const widget = getWidget(name)
if (!widget || widget.value === undefined || widget.value === null) {
return defaultValue
}
const v = widget.value
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
// ---- read widget values with sensible defaults (mirroring backend) ----
const modelVersionRaw = getString('model_version', '').toLowerCase()
if (modelVersionRaw === '')
return '$0.1-0.65/Run (varies with quad, style, texture & quality)'
const styleRaw = getString('style', 'None')
const hasStyle = styleRaw.toLowerCase() !== 'none'
// Backend defaults: texture=true, pbr=true, quad=false, qualities="standard"
const hasTexture = getBool('texture', false)
const hasPbr = getBool('pbr', false)
const quad = getBool('quad', false)
const textureQualityRaw = getString(
'texture_quality',
'standard'
).toLowerCase()
const geometryQualityRaw = getString(
'geometry_quality',
'standard'
).toLowerCase()
const isHdTexture = textureQualityRaw === 'detailed'
const isDetailedGeometry = geometryQualityRaw === 'detailed'
const withTexture = hasTexture || hasPbr
let baseCredits: number
if (modelVersionRaw.includes('v1.4')) {
// V1.4 model: Text=20, Image=30, Refine=30
if (task === 'text') {
baseCredits = 20
} else {
// treat Multiview same as Image if V1.4 is ever used there
baseCredits = 30
}
} else {
// V3.0, V2.5, V2.0 models
if (!withTexture) {
if (task === 'text') {
baseCredits = 10 // Text to 3D without texture
} else {
baseCredits = 20 // Image/Multiview to 3D without texture
}
} else {
if (task === 'text') {
baseCredits = 20 // Text to 3D with standard texture
} else {
baseCredits = 30 // Image/Multiview to 3D with standard texture
}
}
}
// ---- advanced extras on top of base generation ----
let credits = baseCredits
if (hasStyle) credits += 5 // Style
if (quad) credits += 5 // Quad Topology
if (isHdTexture) credits += 10 // HD Texture
if (isDetailedGeometry) credits += 20 // Detailed Geometry Quality
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
/**
* Static pricing data for API nodes, now supporting both strings and functions
*/
@@ -395,6 +512,46 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
Flux2MaxImageNode: {
displayPrice: (node: LGraphNode): string => {
const widthW = node.widgets?.find(
(w) => w.name === 'width'
) as IComboWidget
const heightW = node.widgets?.find(
(w) => w.name === 'height'
) as IComboWidget
const w = Number(widthW?.value)
const h = Number(heightW?.value)
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
// global min/max for this node given schema bounds (1MP..4MP output)
return '$0.07$0.35/Run'
}
// Is the 'images' input connected?
const imagesInput = node.inputs?.find(
(i) => i.name === 'images'
) as INodeInputSlot
const hasRefs =
typeof imagesInput?.link !== 'undefined' && imagesInput.link != null
// Output cost: ceil((w*h)/MP); first MP $0.07, each additional $0.03
const MP = 1024 * 1024
const outMP = Math.max(1, Math.floor((w * h + MP - 1) / MP))
const outputCost = 0.07 + 0.03 * Math.max(outMP - 1, 0)
if (hasRefs) {
// Unknown ref count/size on the frontend:
// min extra is $0.03, max extra is $0.27 (8 MP cap / 8 refs)
const minTotal = outputCost + 0.03
const maxTotal = outputCost + 0.24
return `~$${parseFloat(minTotal.toFixed(3))}$${parseFloat(maxTotal.toFixed(3))}/Run`
}
// Precise text-to-image price
return `$${parseFloat(outputCost.toFixed(3))}/Run`
}
},
OpenAIVideoSora2: {
displayPrice: sora2PricingCalculator
},
@@ -1482,119 +1639,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.10/Run'
else return '$0.15/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
} else {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.15/Run'
else return '$0.20/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
} else {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'text')
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Image to Model
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'image')
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string =>
calculateTripo3DGenerationPrice(node, 'multiview')
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1608,68 +1662,94 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
TripoConvertModelNode: {
displayPrice: '$0.10/Run'
TripoRigNode: {
displayPrice: '$0.25/Run'
},
TripoRetargetRiggedModelNode: {
displayPrice: '$0.10/Run'
},
TripoMultiviewToModelNode: {
TripoConversionNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
const getWidgetValue = (name: string) =>
node.widgets?.find((w) => w.name === name)?.value
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
const getNumber = (name: string, defaultValue: number): number => {
const raw = getWidgetValue(name)
if (raw === undefined || raw === null || raw === '')
return defaultValue
if (typeof raw === 'number')
return Number.isFinite(raw) ? raw : defaultValue
const n = Number(raw)
return Number.isFinite(n) ? n : defaultValue
}
const getBool = (name: string, defaultValue: boolean): boolean => {
const v = getWidgetValue(name)
if (v === undefined || v === null) return defaultValue
if (typeof v === 'number') return v !== 0
const lower = String(v).toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
return defaultValue
}
let hasAdvancedParam = false
// ---- booleans that trigger advanced when true ----
if (getBool('quad', false)) hasAdvancedParam = true
if (getBool('force_symmetry', false)) hasAdvancedParam = true
if (getBool('flatten_bottom', false)) hasAdvancedParam = true
if (getBool('pivot_to_center_bottom', false)) hasAdvancedParam = true
if (getBool('with_animation', false)) hasAdvancedParam = true
if (getBool('pack_uv', false)) hasAdvancedParam = true
if (getBool('bake', false)) hasAdvancedParam = true
if (getBool('export_vertex_colors', false)) hasAdvancedParam = true
if (getBool('animate_in_place', false)) hasAdvancedParam = true
// ---- numeric params with special default sentinels ----
const faceLimit = getNumber('face_limit', -1)
if (faceLimit !== -1) hasAdvancedParam = true
const textureSize = getNumber('texture_size', 4096)
if (textureSize !== 4096) hasAdvancedParam = true
const flattenBottomThreshold = getNumber(
'flatten_bottom_threshold',
0.0
)
if (flattenBottomThreshold !== 0.0) hasAdvancedParam = true
const scaleFactor = getNumber('scale_factor', 1.0)
if (scaleFactor !== 1.0) hasAdvancedParam = true
// ---- string / combo params with non-default values ----
const textureFormatRaw = String(
getWidgetValue('texture_format') ?? 'JPEG'
).toUpperCase()
if (textureFormatRaw !== 'JPEG') hasAdvancedParam = true
const partNamesRaw = String(getWidgetValue('part_names') ?? '')
if (partNamesRaw.trim().length > 0) hasAdvancedParam = true
const fbxPresetRaw = String(
getWidgetValue('fbx_preset') ?? 'blender'
).toLowerCase()
if (fbxPresetRaw !== 'blender') hasAdvancedParam = true
const exportOrientationRaw = String(
getWidgetValue('export_orientation') ?? 'default'
).toLowerCase()
if (exportOrientationRaw !== 'default') hasAdvancedParam = true
const credits = hasAdvancedParam ? 10 : 5
const dollars = credits * 0.01
return `$${dollars.toFixed(2)}/Run`
}
},
TripoRetargetNode: {
displayPrice: '$0.10/Run'
},
TripoRefineNode: {
displayPrice: '$0.30/Run'
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
@@ -1984,6 +2064,7 @@ export const useNodePricing = () => {
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
Flux2ProImageNode: ['width', 'height', 'images'],
Flux2MaxImageNode: ['width', 'height', 'images'],
VeoVideoGenerationNode: ['duration_seconds'],
Veo3VideoGenerationNode: ['model', 'generate_audio'],
Veo3FirstLastFrameNode: ['model', 'generate_audio', 'duration'],
@@ -2019,8 +2100,51 @@ export const useNodePricing = () => {
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoTextToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoImageToModelNode: [
'model_version',
'quad',
'style',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoMultiviewToModelNode: [
'model_version',
'quad',
'texture',
'pbr',
'texture_quality',
'geometry_quality'
],
TripoConversionNode: [
'quad',
'face_limit',
'texture_size',
'texture_format',
'force_symmetry',
'flatten_bottom',
'flatten_bottom_threshold',
'pivot_to_center_bottom',
'scale_factor',
'with_animation',
'pack_uv',
'bake',
'part_names',
'fbx_preset',
'export_vertex_colors',
'export_orientation',
'animate_in_place'
],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],

View File

@@ -389,6 +389,13 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'enable-manager-legacy-ui',
name: 'Use legacy Manager UI',
tooltip: 'Uses the legacy ComfyUI-Manager UI instead of the new UI.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',

View File

@@ -1,5 +1,6 @@
import { remove } from 'es-toolkit'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ISlotType,
INodeInputSlot,
@@ -23,22 +24,41 @@ import type { ComfyApp } from '@/scripts/app'
const INLINE_INPUTS = false
type MatchTypeNode = LGraphNode &
Pick<Required<LGraphNode>, 'comfyMatchType' | 'onConnectionsChange'>
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
comfyDynamic: { matchType: Record<string, Record<string, string>> }
}
type AutogrowNode = LGraphNode &
Pick<Required<LGraphNode>, 'onConnectionsChange' | 'widgets'> & {
comfyDynamic: {
autogrow: Record<
string,
{
min: number
max: number
inputSpecs: InputSpecV2[]
prefix?: string
names?: string[]
}
>
}
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
if (input.widget?.name) return
node.widgets ??= []
const { widget } = input
if (widget && node.widgets.some((w) => w.name === widget.name)) return
node.widgets.push({
name: input.name,
y: 0,
type: 'shim',
options: {},
draw(ctx, _n, _w, y) {
ctx.save()
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.fillText(input.label ?? input.name, 20, y + 15)
ctx.restore()
}
},
name: input.name,
options: {},
serialize: false,
type: 'shim',
y: 0
})
input.alwaysVisible = true
input.widget = { name: input.name }
@@ -66,72 +86,47 @@ function dynamicComboWidget(
appArg,
widgetName
)
let currentDynamicNames: string[] = []
function isInGroup(e: { name: string }): boolean {
return e.name.startsWith(inputName + '.')
}
const updateWidgets = (value?: string) => {
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
const inputsToRemove: Record<string, INodeInputSlot> = {}
for (const name of currentDynamicNames) {
const input = node.inputs.find((input) => input.name === name)
if (input) inputsToRemove[input.name] = input
const widgetIndex = node.widgets.findIndex(
(widget) => widget.name === name
)
if (widgetIndex === -1) continue
node.widgets[widgetIndex].value = undefined
node.widgets.splice(widgetIndex, 1)
}
currentDynamicNames = []
if (!newSpec) {
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
node.removeInput(inputIndex)
}
return
}
const removedInputs = remove(node.inputs, isInGroup)
remove(node.widgets, isInGroup)
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
const initialInputIndex =
node.inputs.findIndex((i) => i.name === widget.name) + 1
let startingInputLength = node.inputs.length
const startingInputLength = node.inputs.length
if (insertionPoint === 0)
throw new Error("Dynamic widget doesn't exist on node")
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[newSpec.required, false],
[newSpec.optional, true]
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
newSpec.required,
newSpec.optional
]
for (const [inputType, isOptional] of inputTypes)
inputTypes.forEach((inputType, idx) => {
for (const key in inputType ?? {}) {
const name = `${widget.name}.${key}`
const specToAdd = transformInputSpecV1ToV2(inputType![key], {
name,
isOptional
isOptional: idx !== 0
})
specToAdd.display_name = key
addNodeInput(node, specToAdd)
currentDynamicNames.push(name)
if (INLINE_INPUTS) ensureWidgetForInput(node, node.inputs.at(-1)!)
if (
!inputsToRemove[name] ||
Array.isArray(inputType![key][0]) ||
!LiteGraph.isValidConnection(
inputsToRemove[name].type,
inputType![key][0]
)
)
continue
node.inputs.at(-1)!.link = inputsToRemove[name].link
inputsToRemove[name].link = null
const newInputs = node.inputs
.slice(startingInputLength)
.filter((inp) => inp.name.startsWith(name))
for (const newInput of newInputs) {
if (INLINE_INPUTS && !newInput.widget)
ensureWidgetForInput(node, newInput)
}
}
})
for (const input of Object.values(inputsToRemove)) {
const inputIndex = node.inputs.findIndex((inp) => inp === input)
if (inputIndex === -1) continue
if (inputIndex < initialInputIndex) startingInputLength--
node.removeInput(inputIndex)
}
const inputInsertionPoint =
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
@@ -157,6 +152,28 @@ function dynamicComboWidget(
)
//assume existing inputs are in correct order
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
for (const input of removedInputs) {
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
if (inputIndex === -1) {
node.inputs.push(input)
node.removeInput(node.inputs.length - 1)
} else {
node.inputs[inputIndex].link = input.link
if (!input.link) continue
const link = node.graph?.links?.[input.link]
if (!link) continue
link.target_slot = inputIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
}
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return
node._setConcreteSlots()
@@ -243,8 +260,9 @@ function changeOutputType(
}
function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (node.comfyMatchType) return
node.comfyMatchType = {}
if (node.comfyDynamic?.matchType) return
node.comfyDynamic ??= {}
node.comfyDynamic.matchType = {}
const outputGroups = node.constructor.nodeData?.output_matchtypes
node.onConnectionsChange = useChainCallback(
@@ -258,9 +276,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !this.graph || !input) return
const [matchKey, matchGroup] = Object.entries(this.comfyMatchType).find(
([, group]) => input.name in group
) ?? ['', undefined]
const [matchKey, matchGroup] = Object.entries(
this.comfyDynamic.matchType
).find(([, group]) => input.name in group) ?? ['', undefined]
if (!matchGroup) return
if (iscon && linf) {
const { output, subgraphInput } = linf.resolve(this.graph)
@@ -317,8 +335,8 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
const typedSpec = { ...inputSpec, type: allowed_types }
addNodeInput(node, typedSpec)
withComfyMatchType(node)
node.comfyMatchType[template_id] ??= {}
node.comfyMatchType[template_id][name] = allowed_types
node.comfyDynamic.matchType[template_id] ??= {}
node.comfyDynamic.matchType[template_id][name] = allowed_types
//TODO: instead apply on output add?
//ensure outputs get updated
@@ -329,160 +347,215 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
)
}
function applyAutogrow(node: LGraphNode, untypedInputSpec: InputSpecV2) {
function autogrowOrdinalToName(
ordinal: number,
key: string,
groupName: string,
node: AutogrowNode
) {
const {
names,
prefix = '',
inputSpecs
} = node.comfyDynamic.autogrow[groupName]
const baseName = names
? names[ordinal]
: (inputSpecs.length == 1 ? prefix : key) + ordinal
return { name: `${groupName}.${baseName}`, display_name: baseName }
}
function addAutogrowGroup(
ordinal: number,
groupName: string,
node: AutogrowNode
) {
const { addNodeInput } = useLitegraphService()
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
if (ordinal >= max) return
const parseResult = zAutogrowOptions.safeParse(untypedInputSpec)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const namedSpecs = inputSpecs.map((input) => ({
...input,
isOptional: ordinal >= (min ?? 0) || input.isOptional,
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
}))
const { input, min, names, prefix, max } = inputSpec.template
const inputTypes: [Record<string, InputSpec> | undefined, boolean][] = [
[input.required, false],
[input.optional, true]
]
const inputsV2 = inputTypes.flatMap(([inputType, isOptional]) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional })
const newInputs = namedSpecs
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
app.canvas?.setDirty(true, true)
}
function nameToInputIndex(name: string) {
const index = node.inputs.findIndex((input) => input.name === name)
if (index === -1) throw new Error('Failed to find input')
return index
}
function nameToInput(name: string) {
return node.inputs[nameToInputIndex(name)]
const ORDINAL_REGEX = /\d+$/
function resolveAutogrowOrdinal(
inputName: string,
groupName: string,
node: AutogrowNode
): number | undefined {
//TODO preslice groupname?
const name = inputName.slice(groupName.length + 1)
const { names } = node.comfyDynamic.autogrow[groupName]
if (names) {
const ordinal = names.findIndex((s) => s === name)
return ordinal === -1 ? undefined : ordinal
}
const match = name.match(ORDINAL_REGEX)
if (!match) return undefined
const ordinal = parseInt(match[0])
return ordinal !== ordinal ? undefined : ordinal
}
function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName)
)
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (
!lastInput ||
ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
)
return
addAutogrowGroup(ordinal + 1, groupName, node)
}
function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
if (!input) return
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (ordinal == undefined || ordinal + 1 < min) return
//In the distance, someone shouting YAGNI
const trackedInputs: string[][] = []
function addInputGroup(insertionIndex: number) {
const ordinal = trackedInputs.length
const inputGroup = inputsV2.map((input) => ({
...input,
name: names
? names[ordinal]
: ((inputsV2.length == 1 ? prefix : input.name) ?? '') + ordinal,
isOptional: ordinal >= (min ?? 0) || input.isOptional
}))
const newInputs = inputGroup
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputsV2.length !== 1) ensureWidgetForInput(node, input)
return input
})
spliceInputs(node, insertionIndex, 0, ...newInputs)
trackedInputs.push(inputGroup.map((inp) => inp.name))
app.canvas?.setDirty(true, true)
//resolve all inputs in group
const groupInputs = node.inputs.filter(
(inp) =>
inp.name.startsWith(groupName + '.') &&
inp.name.lastIndexOf('.') === groupName.length
)
const stride = inputSpecs.length
if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs')
return
}
for (let i = 0; i < (min || 1); i++) addInputGroup(node.inputs.length)
function removeInputGroup(inputName: string) {
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inpName) => inpName === inputName)
)
if (groupIndex == -1) throw new Error('Failed to find group')
const group = trackedInputs[groupIndex]
for (const nameToRemove of group) {
const inputIndex = nameToInputIndex(nameToRemove)
const input = spliceInputs(node, inputIndex, 1)[0]
if (!input.widget?.name) continue
const widget = node.widgets?.find((w) => w.name === input.widget!.name)
if (!widget) return
widget.value = undefined
node.removeWidget(widget)
}
trackedInputs.splice(groupIndex, 1)
node.size[1] = node.computeSize([...node.size])[1]
app.canvas?.setDirty(true, true)
}
function inputConnected(index: number) {
const input = node.inputs[index]
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
groupIndex + 1 === trackedInputs.length &&
trackedInputs.length < (max ?? names?.length ?? 100)
app.canvas?.setDirty(true, true)
//groupBy would be nice here, but may not be supported
for (let column = 0; column < stride; column++) {
for (
let bubbleOrdinal = ordinal * stride + column;
bubbleOrdinal + stride < groupInputs.length;
bubbleOrdinal += stride
) {
const lastInput = trackedInputs[groupIndex].at(-1)
if (!lastInput) return
const insertionIndex = nameToInputIndex(lastInput) + 1
if (insertionIndex === 0) throw new Error('Failed to find Input')
addInputGroup(insertionIndex)
const curInput = groupInputs[bubbleOrdinal]
curInput.link = groupInputs[bubbleOrdinal + stride].link
if (!curInput.link) continue
const link = node.graph?.links[curInput.link]
if (!link) continue
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
}
function inputDisconnected(index: number) {
const input = node.inputs[index]
if (trackedInputs.length === 1) return
const groupIndex = trackedInputs.findIndex((ig) =>
ig.some((inputName) => inputName === input.name)
)
if (groupIndex == -1) throw new Error('Failed to find group')
if (
trackedInputs[groupIndex].some(
(inputName) => nameToInput(inputName).link != null
)
)
return
if (groupIndex + 1 < (min ?? 0)) return
//For each group from here to last group, bubble swap links
for (let column = 0; column < trackedInputs[0].length; column++) {
let prevInput = nameToInputIndex(trackedInputs[groupIndex][column])
for (let i = groupIndex + 1; i < trackedInputs.length; i++) {
const curInput = nameToInputIndex(trackedInputs[i][column])
const linkId = node.inputs[curInput].link
node.inputs[prevInput].link = linkId
const link = linkId && node.graph?.links?.[linkId]
if (link) link.target_slot = prevInput
prevInput = curInput
}
node.inputs[prevInput].link = null
}
if (
trackedInputs.at(-2) &&
!trackedInputs.at(-2)?.some((name) => !!nameToInput(name).link)
)
removeInputGroup(trackedInputs.at(-1)![0])
const removalChecks = groupInputs.slice((min - 1) * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
}
const toRemove = removalChecks.slice(i + stride * 2)
remove(node.inputs, (inp) => toRemove.includes(inp))
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
remove(node.widgets, (w) => w.name === widgetName)
}
node.size[1] = node.computeSize([...node.size])[1]
}
function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
if (node.comfyDynamic?.autogrow) return
node.comfyDynamic ??= {}
node.comfyDynamic.autogrow = {}
let pendingConnection: number | undefined
let swappingConnection = false
const originalOnConnectInput = node.onConnectInput
node.onConnectInput = function (slot: number, ...args) {
pendingConnection = slot
requestAnimationFrame(() => (pendingConnection = undefined))
return originalOnConnectInput?.apply(this, [slot, ...args]) ?? true
}
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
(
type: ISlotType,
index: number,
function (
this: AutogrowNode,
contype: ISlotType,
slot: number,
iscon: boolean,
linf: LLink | null | undefined
) => {
if (type !== NodeSlotType.INPUT) return
const inputName = node.inputs[index].name
if (!trackedInputs.flat().some((name) => name === inputName)) return
if (iscon) {
) {
const input = this.inputs[slot]
if (contype !== LiteGraph.INPUT || !input) return
//Return if input isn't known autogrow
const key = input.name.slice(0, input.name.lastIndexOf('.'))
const autogrowGroup = this.comfyDynamic.autogrow[key]
if (!autogrowGroup) return
if (app.configuringGraph && input.widget)
ensureWidgetForInput(node, input)
if (iscon && linf) {
if (swappingConnection || !linf) return
inputConnected(index)
autogrowInputConnected(slot, this)
} else {
if (pendingConnection === index) {
if (pendingConnection === slot) {
swappingConnection = true
requestAnimationFrame(() => (swappingConnection = false))
return
}
requestAnimationFrame(() => inputDisconnected(index))
requestAnimationFrame(() => autogrowInputDisconnected(slot, this))
}
}
)
}
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
withComfyAutogrow(node)
const parseResult = zAutogrowOptions.safeParse(inputSpecV2)
if (!parseResult.success) throw new Error('invalid Autogrow spec')
const inputSpec = parseResult.data
const { input, min = 1, names, prefix, max = 100 } = inputSpec.template
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
input.required,
input.optional
]
const inputsV2 = inputTypes.flatMap((inputType, index) =>
Object.entries(inputType ?? {}).map(([name, v]) =>
transformInputSpecV1ToV2(v, { name, isOptional: index === 1 })
)
)
node.comfyDynamic.autogrow[inputSpecV2.name] = {
names,
min,
max: names?.length ?? max,
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -257,6 +257,8 @@ export class PrimitiveNode extends LGraphNode {
undefined,
inputData
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
let filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter

View File

@@ -8564,9 +8564,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else {
// Non-node children (nested groups, reroutes)
child.move(deltaX, deltaY)
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
}
}
}

View File

@@ -416,7 +416,7 @@ export class LGraphNode
selected?: boolean
showAdvanced?: boolean
declare comfyMatchType?: Record<string, Record<string, string>>
declare comfyDynamic?: Record<string, object>
declare comfyClass?: string
declare isVirtualNode?: boolean
applyToGraph?(extraLinks?: LLink[]): void
@@ -2000,7 +2000,7 @@ export class LGraphNode
* @param out `x, y, width, height` are written to this array.
* @param ctx The canvas context to use for measuring text.
*/
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
measure(out: Rect, ctx?: CanvasRenderingContext2D): void {
const titleMode = this.title_mode
const renderTitle =
titleMode != TitleMode.TRANSPARENT_TITLE &&
@@ -2013,11 +2013,13 @@ export class LGraphNode
out[2] = this.size[0]
out[3] = this.size[1] + titleHeight
} else {
ctx.font = this.innerFontStyle
if (ctx) ctx.font = this.innerFontStyle
this._collapsed_width = Math.min(
this.size[0],
ctx.measureText(this.getTitle() ?? '').width +
LiteGraph.NODE_TITLE_HEIGHT * 2
ctx
? ctx.measureText(this.getTitle() ?? '').width +
LiteGraph.NODE_TITLE_HEIGHT * 2
: 0
)
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
out[3] = LiteGraph.NODE_TITLE_HEIGHT
@@ -2047,7 +2049,7 @@ export class LGraphNode
* Calculates the render area of this node, populating both {@link boundingRect} and {@link renderArea}.
* Called automatically at the start of every frame.
*/
updateArea(ctx: CanvasRenderingContext2D): void {
updateArea(ctx?: CanvasRenderingContext2D): void {
const bounds = this.#boundingRect
this.measure(bounds, ctx)
this.onBounding?.(bounds)

View File

@@ -294,6 +294,8 @@
"uninstall": "Uninstall",
"uninstalling": "Uninstalling {id}",
"update": "Update",
"tryUpdate": "Try Update",
"tryUpdateTooltip": "Pull latest changes from repository. Nightly versions may have updates that cannot be detected automatically.",
"uninstallSelected": "Uninstall Selected",
"updateSelected": "Update Selected",
"updateAll": "Update All",
@@ -807,6 +809,7 @@
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"customNodesManager": "Custom Nodes Manager",
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel"
@@ -1333,6 +1336,10 @@
"disable-metadata": {
"name": "Disable saving prompt metadata in files."
},
"enable-manager-legacy-ui": {
"name": "Use legacy Manager UI",
"tooltip": "Uses the legacy ComfyUI-Manager UI instead of the new UI."
},
"disable-all-custom-nodes": {
"name": "Disable loading all custom nodes."
},
@@ -2053,7 +2060,7 @@
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"valueControl": {
"header": {
"prefix": "Automatically update the value",
"after": "AFTER",
@@ -2066,9 +2073,11 @@
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"incrementDesc": "Adds 1 to value or selects the next option",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"decrementDesc": "Subtracts 1 from value or selects the previous option",
"fixed": "Fixed Value",
"fixedDesc": "Leaves value unchanged",
"editSettings": "Edit control settings"
}
},
@@ -2444,4 +2453,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -55,7 +55,6 @@
variant="gray"
:label="formattedDuration"
/>
<SquareChip v-if="fileFormat" variant="gray" :label="fileFormat" />
</div>
<!-- Media actions - show on hover or when playing -->
@@ -266,12 +265,6 @@ const formattedDuration = computed(() => {
return formatDuration(Number(duration))
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (fileKind.value === 'audio') {
return '-translate-y-11'
@@ -289,7 +282,7 @@ const showStaticChips = computed(
!!asset &&
!isHovered.value &&
!isVideoPlaying.value &&
(formattedDuration.value || fileFormat.value)
formattedDuration.value
)
// Show action overlay when hovered OR playing

View File

@@ -16,7 +16,6 @@
/>
<div
ref="containerRef"
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
:style="containerStyles"
>
@@ -51,12 +50,7 @@
}"
/>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<canvas :width="width" :height="height" class="minimap-canvas" />
<div class="minimap-viewport" :style="viewportStyles" />
@@ -89,8 +83,6 @@ const minimapRef = ref<HTMLDivElement>()
const {
initialized,
visible,
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,

View File

@@ -2,16 +2,20 @@
<div
v-if="imageUrls.length > 0"
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col px-2"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@keydown="handleKeyDown"
>
<!-- Video Wrapper -->
<div
ref="videoWrapperEl"
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
tabindex="0"
role="region"
:aria-label="$t('g.videoPreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
@@ -27,18 +31,18 @@
<!-- Loading State -->
<Skeleton
v-if="isLoading && !videoError"
v-if="showLoader && !videoError"
class="absolute inset-0 size-full"
border-radius="5px"
width="16rem"
height="16rem"
width="100%"
height="100%"
/>
<!-- Main Video -->
<video
v-if="!videoError"
:src="currentVideoUrl"
:class="cn('block size-full object-contain', isLoading && 'invisible')"
:class="cn('block size-full object-contain', showLoader && 'invisible')"
controls
loop
playsinline
@@ -47,10 +51,13 @@
/>
<!-- Floating Action Buttons (appear on hover) -->
<div v-if="isHovered" class="actions absolute top-2 right-2 flex gap-1">
<div
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-2.5"
>
<!-- Download Button -->
<button
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:class="actionButtonClass"
:title="$t('g.downloadVideo')"
:aria-label="$t('g.downloadVideo')"
@click="handleDownload"
@@ -60,7 +67,7 @@
<!-- Close Button -->
<button
class="action-btn cursor-pointer rounded-lg border-0 bg-white p-2 text-black shadow-sm transition-all duration-200 hover:bg-smoke-100"
:class="actionButtonClass"
:title="$t('g.removeVideo')"
:aria-label="$t('g.removeVideo')"
@click="handleRemove"
@@ -94,7 +101,7 @@
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
<span v-else-if="showLoader" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
@@ -126,12 +133,18 @@ const props = defineProps<VideoPreviewProps>()
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const actionButtonClass =
'flex h-8 min-h-8 items-center justify-center gap-2.5 rounded-lg border-0 bg-button-surface px-2 py-2 text-button-surface-contrast shadow-sm transition-colors duration-200 hover:bg-button-hover-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-button-surface-contrast focus-visible:ring-offset-2 focus-visible:ring-offset-transparent cursor-pointer'
// Component state
const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const videoError = ref(false)
const isLoading = ref(false)
const showLoader = ref(false)
const videoWrapperEl = ref<HTMLDivElement>()
// Computed values
const currentVideoUrl = computed(() => props.imageUrls[currentIndex.value])
@@ -149,16 +162,16 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
videoError.value = false
isLoading.value = newUrls.length > 0
showLoader.value = newUrls.length > 0
},
{ deep: true }
{ deep: true, immediate: true }
)
// Event handlers
const handleVideoLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLVideoElement)) return
const video = event.target
isLoading.value = false
showLoader.value = false
videoError.value = false
if (video.videoWidth && video.videoHeight) {
actualDimensions.value = `${video.videoWidth} x ${video.videoHeight}`
@@ -166,7 +179,7 @@ const handleVideoLoad = (event: Event) => {
}
const handleVideoError = () => {
isLoading.value = false
showLoader.value = false
videoError.value = true
actualDimensions.value = null
}
@@ -194,7 +207,7 @@ const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
currentIndex.value = index
actualDimensions.value = null
isLoading.value = true
showLoader.value = true
videoError.value = false
}
}
@@ -207,6 +220,16 @@ const handleMouseLeave = () => {
isHovered.value = false
}
const handleFocusIn = () => {
isFocused.value = true
}
const handleFocusOut = (event: FocusEvent) => {
if (!videoWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}
const getNavigationDotClass = (index: number) => {
return [
'w-2 h-2 rounded-full transition-all duration-200 border-0 cursor-pointer',

View File

@@ -51,7 +51,10 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<div class="flex flex-col justify-center items-center relative">
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
@@ -145,6 +148,7 @@ import {
LiteGraph,
RenderShape
} from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -165,6 +169,7 @@ import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { isTransparent } from '@/utils/colorUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -215,6 +220,8 @@ const hasAnyError = computed((): boolean => {
)
})
const displayHeader = computed(() => nodeData.titleMode !== TitleMode.NO_TITLE)
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
const bypassed = computed(
(): boolean => nodeData.mode === LGraphEventMode.BYPASS
@@ -297,19 +304,26 @@ const handleContextMenu = (event: MouseEvent) => {
}
onMounted(() => {
// Set initial DOM size from layout store, but respect intrinsic content minimum
if (size.value && nodeContainerRef.value) {
nodeContainerRef.value.style.setProperty(
'--node-width',
`${size.value.width}px`
)
nodeContainerRef.value.style.setProperty(
'--node-height',
`${size.value.height}px`
)
}
initSizeStyles()
})
/**
* Set initial DOM size from layout store, but respect intrinsic content minimum.
* Important: nodes can mount in a collapsed state, and the collapse watcher won't
* run initially. Match the collapsed runtime behavior by writing to the correct
* CSS variables on mount.
*/
function initSizeStyles() {
const el = nodeContainerRef.value
const { width, height } = size.value
if (!el) return
const suffix = isCollapsed.value ? '-x' : ''
el.style.setProperty(`--node-width${suffix}`, `${width}px`)
el.style.setProperty(`--node-height${suffix}`, `${height}px`)
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
@@ -327,6 +341,8 @@ const { startResize } = useNodeResize((result, element) => {
})
const handleResizePointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (!shouldHandleNodePointerEvents.value) return
if (nodeData.flags?.pinned) return
startResize(event)
}
@@ -362,6 +378,13 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
return 'border-0'
return ''
})

View File

@@ -1,7 +1,12 @@
<template>
<div
:data-node-id="nodeData.id"
class="bg-component-node-background lg-node absolute pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke"
:class="
cn(
'bg-component-node-background lg-node pb-1 contain-style contain-layout w-[350px] rounded-2xl touch-none flex flex-col border-1 border-solid outline-transparent outline-2 border-node-stroke',
position
)
"
>
<div
class="flex flex-col justify-center items-center relative pointer-events-none"
@@ -37,9 +42,11 @@ import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { cn } from '@/utils/tailwindUtil'
const { nodeDef } = defineProps<{
const { nodeDef, position = 'absolute' } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
}>()
const widgetStore = useWidgetStore()

View File

@@ -53,9 +53,9 @@
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
@@ -168,12 +168,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
name: widget.name,
type: widget.type,
value: widget.value,
label: widget.label,
options: widgetOptions,
callback: widget.callback,
spec: widget.spec,
borderStyle: widget.borderStyle,
controlWidget: widget.controlWidget
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.label,
nodeType: widget.nodeType,
options: widgetOptions,
spec: widget.spec
}
function updateHandler(value: WidgetValue) {

View File

@@ -57,7 +57,10 @@ function useNodeDragIndividual() {
const selectedNodes = toValue(selectedNodeIds)
// capture the starting positions of all other selected nodes
if (selectedNodes?.has(nodeId) && selectedNodes.size > 1) {
// Only move other selected items if the dragged node is part of the selection
const isDraggedNodeInSelection = selectedNodes?.has(nodeId)
if (isDraggedNodeInSelection && selectedNodes.size > 1) {
otherSelectedNodesStartPositions = new Map()
for (const id of selectedNodes) {
@@ -73,9 +76,15 @@ function useNodeDragIndividual() {
otherSelectedNodesStartPositions = null
}
// Capture selected groups (filter from selectedItems which only contains selected items)
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
// Capture selected groups only if the dragged node is part of the selection
// This prevents groups from moving when dragging an unrelated node
if (isDraggedNodeInSelection) {
selectedGroups = toValue(selectedItems).filter(isLGraphGroup)
lastCanvasDelta = { x: 0, y: 0 }
} else {
selectedGroups = null
lastCanvasDelta = null
}
mutations.setSource(LayoutSource.Vue)
}

View File

@@ -1,17 +1,14 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import ToggleSwitch from 'primevue/toggleswitch'
import RadioButton from 'primevue/radiobutton'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { NumberControlMode } from '../composables/useStepperControl'
import type { ControlOptions } from '@/types/simplifiedWidget'
type ControlOption = {
description: string
mode: NumberControlMode
mode: ControlOptions
icon?: string
text?: string
title: string
@@ -19,43 +16,36 @@ type ControlOption = {
const popover = ref()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const toggle = (event: Event) => {
popover.value.toggle(event)
}
defineExpose({ toggle })
const ENABLE_LINK_TO_GLOBAL = false
const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
mode: 'fixed',
icon: 'icon-[lucide--pencil-off]',
title: 'fixed',
description: 'fixedDesc'
},
{
mode: NumberControlMode.INCREMENT,
mode: 'increment',
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: NumberControlMode.DECREMENT,
mode: 'decrement',
text: '-1',
title: 'decrement',
description: 'decrementDesc'
},
{
mode: 'randomize',
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
}
]
@@ -63,27 +53,7 @@ const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)
const props = defineProps<{
controlMode: NumberControlMode
}>()
const emit = defineEmits<{
'update:controlMode': [mode: NumberControlMode]
}>()
const handleToggle = (mode: NumberControlMode) => {
if (props.controlMode === mode) return
emit('update:controlMode', mode)
}
const isActive = (mode: NumberControlMode) => {
return props.controlMode === mode
}
const handleEditSettings = () => {
popover.value.hide()
dialogService.showSettingsDialog()
}
const controlMode = defineModel<ControlOptions>()
</script>
<template>
@@ -93,15 +63,15 @@ const handleEditSettings = () => {
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.header.prefix') }}
{{ $t('widgets.valueControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.header.before')
: $t('widgets.numberControl.header.after')
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
}}
</span>
{{ $t('widgets.numberControl.header.postfix') }}
{{ $t('widgets.valueControl.header.postfix') }}
</div>
<div class="space-y-2">
@@ -131,41 +101,26 @@ const handleEditSettings = () => {
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
{{ $t(`widgets.valueControl.${option.description}`) }}
</div>
</div>
</div>
<ToggleSwitch
:model-value="isActive(option.mode)"
<RadioButton
v-model="controlMode"
class="flex-shrink-0"
@update:model-value="handleToggle(option.mode)"
:input-id="option.mode"
:value="option.mode"
/>
</div>
</div>
<div class="border-t border-border-subtle"></div>
<Button
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>
</template>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
import WidgetWithControl from './WidgetWithControl.vue'
const props = defineProps<{
widget: SimplifiedWidget<number>
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
</script>
<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
v-model="modelValue"
:widget="widget as SimplifiedControlWidget<number>"
:component="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
/>
<component
:is="
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-else
v-model="modelValue"
:widget="widget"
v-bind="$attrs"

View File

@@ -110,7 +110,7 @@ const buttonTooltip = computed(() => {
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>

View File

@@ -1,67 +0,0 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { NumberControlMode } from '../composables/useStepperControl'
import { useStepperControl } from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const popover = ref()
const handleControlChange = (newValue: number) => {
modelValue.value = newValue
}
const { controlMode, controlButtonIcon } = useStepperControl(
modelValue,
{
...props.widget.options,
onChange: handleControlChange
},
props.widget.controlWidget!.value
)
const setControlMode = (mode: NumberControlMode) => {
controlMode.value = mode
props.widget.controlWidget!.update(mode)
}
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<WidgetInputNumberInput
v-model="modelValue"
:widget
class="grid grid-cols-subgrid col-span-2"
>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
</WidgetInputNumberInput>
<NumberControlPopover
ref="popover"
:control-mode
@update:control-mode="setControlMode"
/>
</div>
</template>

View File

@@ -1,14 +1,20 @@
<template>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
<WidgetWithControl
v-else-if="widget.controlWidget"
:component="WidgetSelectDefault"
:widget="widget as StringControlWidget"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>
@@ -20,13 +26,19 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
type StringControlWidget = SimplifiedControlWidget<string | undefined>
const props = defineProps<{
widget: SimplifiedWidget<string | undefined>
nodeType?: string
@@ -89,10 +101,9 @@ const isAssetMode = computed(() => {
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
props.nodeType,
props.widget.name
)
const isEligible =
assetService.isAssetBrowserEligible(props.nodeType, props.widget.name) ||
props.widget.type === 'asset'
return isUsingAssetAPI && isEligible
}

View File

@@ -13,11 +13,14 @@
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: 'truncate min-w-[4ch]',
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
/>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
<slot />
</div>
</WidgetLayoutField>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts" generic="T extends WidgetValue">
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import type { Component } from 'vue'
import type {
SimplifiedControlWidget,
WidgetValue
} from '@/types/simplifiedWidget'
const ValueControlPopover = defineAsyncComponent(
() => import('./ValueControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedControlWidget<T>
component: Component
}>()
const modelValue = defineModel<T>()
const popover = ref()
const controlModel = ref(props.widget.controlWidget.value)
const controlButtonIcon = computed(() => {
switch (controlModel.value) {
case 'increment':
return 'pi pi-plus'
case 'decrement':
return 'pi pi-minus'
case 'fixed':
return 'icon-[lucide--pencil-off]'
default:
return 'icon-[lucide--shuffle]'
}
})
watch(controlModel, props.widget.controlWidget.update)
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
<Button
variant="link"
size="small"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@pointerdown.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>
</component>
<ValueControlPopover ref="popover" v-model="controlModel" />
</div>
</template>

View File

@@ -69,10 +69,11 @@ const searchQuery = defineModel<string>('searchQuery')
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="absolute inset-0 flex items-center justify-center"
class="h-50 col-span-full flex items-center justify-center"
>
<i
:title="$t('g.noItems')"
:aria-label="$t('g.noItems')"
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
/>
</div>

View File

@@ -265,14 +265,19 @@ const renderPreview = (
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
deferredImageRenders.push(() => {
ctx.save()
ctx.setTransform(transform)
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Inter, sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
ctx.restore()
})
return isClicking
}

View File

@@ -1,111 +0,0 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import type { ControlOptions } from '@/types/simplifiedWidget'
import { numberControlRegistry } from '../services/NumberControlRegistry'
export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}
interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}
function convertToEnum(str?: ControlOptions): NumberControlMode {
switch (str) {
case 'fixed':
return NumberControlMode.FIXED
case 'increment':
return NumberControlMode.INCREMENT
case 'decrement':
return NumberControlMode.DECREMENT
case 'randomize':
return NumberControlMode.RANDOMIZE
}
return NumberControlMode.RANDOMIZE
}
function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.FIXED:
return 'icon-[lucide--pencil-off]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}
export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions,
defaultValue?: ControlOptions
) {
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
const controlId = Symbol('numberControl')
const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
const safeMax = Math.min(2 ** 50, max)
const safeMin = Math.max(-(2 ** 50), min)
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step
let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(safeMax, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(safeMin, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
break
default:
return
}
if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}
// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})
// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
const controlButtonIcon = useControlButtonIcon(controlMode)
return {
applyControl,
controlButtonIcon,
controlMode
}
}

View File

@@ -1,59 +0,0 @@
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()
/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}
/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}
/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}
/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}
/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}
// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()
/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

View File

@@ -31,7 +31,6 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1224,12 +1223,7 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {
// @note: Set view after the graph has been rendered once. fitView uses
// boundingRect on nodes to calculate the view bounds, which only become
// available after the first render.
requestAnimationFrame(() => {
useLitegraphService().fitView()
})
useLitegraphService().fitView()
}
}
} catch (error) {
@@ -1359,7 +1353,6 @@ export class ComfyApp {
forEachNode(this.rootGraph, (node) => {
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
})
executeNumberControls('before')
const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
@@ -1404,7 +1397,6 @@ export class ComfyApp {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
executeWidgetsCallback(queuedNodes, 'afterQueued')
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()
}

View File

@@ -868,6 +868,13 @@ export const useLitegraphService = () => {
app.canvas.animateToBounds(graphNode.boundingRect)
}
function ensureBounds(nodes: LGraphNode[]) {
for (const node of nodes) {
if (!node.boundingRect.every((i) => i === 0)) continue
node.updateArea()
}
}
/**
* Resets the canvas view to the default
*/
@@ -881,11 +888,10 @@ export const useLitegraphService = () => {
}
function fitView() {
const canvas = canvasStore.canvas
if (!canvas) return
const canvas = canvasStore.getCanvas()
const nodes = canvas.graph?.nodes
if (!nodes) return
ensureBounds(nodes)
const bounds = createBounds(nodes)
if (!bounds) return

View File

@@ -3,6 +3,7 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
import type {
@@ -358,10 +359,21 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
node: LGraphNode,
widgetName: string
): InputSpecV2 | undefined {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
if (!node.isSubgraphNode()) {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
return nodeDef.inputs[widgetName]
}
const widget = node.widgets?.find((w) => w.name === widgetName)
//TODO: resolve spec for linked
if (!widget || !isProxyWidget(widget)) return undefined
const { nodeId, widgetName: subWidgetName } = widget._overlay
const subNode = node.subgraph.getNodeById(nodeId)
if (!subNode) return undefined
return getInputSpecForWidget(subNode, subWidgetName)
}
/**

View File

@@ -64,6 +64,9 @@ export interface SimplifiedWidget<
/** Widget options including filtered PrimeVue props */
options?: O
/** Override for use with subgraph promoted asset widgets*/
nodeType?: string
/** Optional serialization method for custom value handling */
serializeValue?: () => any
@@ -72,3 +75,10 @@ export interface SimplifiedWidget<
controlWidget?: SafeControlWidget
}
export interface SimplifiedControlWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
> extends SimplifiedWidget<T, O> {
controlWidget: SafeControlWidget
}

View File

@@ -21,6 +21,15 @@ export interface ColorAdjustOptions {
opacity?: number
}
export function isTransparent(color: string) {
if (color === 'transparent') return true
if (color[0] === '#') {
if (color.length === 5) return color[4] === '0'
if (color.length === 9) return color.substring(7) === '00'
}
return false
}
function rgbToHsl({ r, g, b }: RGB): HSL {
r /= 255
g /= 255

View File

@@ -63,7 +63,7 @@ const {
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const { isUpdateAvailable } = usePackUpdateStatus(() => nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()

View File

@@ -0,0 +1,59 @@
<template>
<Button
v-tooltip.top="$t('manager.tryUpdateTooltip')"
variant="textonly"
:size
:disabled="isUpdating"
@click="tryUpdate"
>
<DotSpinner
v-if="isUpdating"
duration="1s"
:size="size === 'sm' ? 12 : 16"
/>
<span>{{ isUpdating ? t('g.updating') : t('manager.tryUpdate') }}</span>
</Button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
type NodePack = components['schemas']['Node']
const { nodePack, size } = defineProps<{
nodePack: NodePack
size?: ButtonVariants['size']
}>()
const { t } = useI18n()
const managerStore = useComfyManagerStore()
const isUpdating = ref(false)
async function tryUpdate() {
if (!nodePack.id) {
console.warn('Pack missing required id:', nodePack)
return
}
isUpdating.value = true
try {
await managerStore.updatePack.call({
id: nodePack.id,
version: 'nightly'
})
managerStore.updatePack.clear()
} catch (error) {
console.error('Nightly update failed:', error)
} finally {
isUpdating.value = false
}
}
</script>

View File

@@ -5,7 +5,14 @@
<InfoPanelHeader
:node-packs="[nodePack]"
:has-conflict="hasCompatibilityIssues"
/>
>
<template v-if="canTryNightlyUpdate" #install-button>
<div class="flex w-full justify-center gap-2">
<PackTryUpdateButton :node-pack="nodePack" size="md" />
<PackUninstallButton :node-packs="[nodePack]" size="md" />
</div>
</template>
</InfoPanelHeader>
</div>
<div
ref="scrollContainer"
@@ -68,9 +75,12 @@ import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
import PackVersionBadge from '@/workbench/extensions/manager/components/manager/PackVersionBadge.vue'
import PackEnableToggle from '@/workbench/extensions/manager/components/manager/button/PackEnableToggle.vue'
import PackTryUpdateButton from '@/workbench/extensions/manager/components/manager/button/PackTryUpdateButton.vue'
import PackUninstallButton from '@/workbench/extensions/manager/components/manager/button/PackUninstallButton.vue'
import InfoPanelHeader from '@/workbench/extensions/manager/components/manager/infoPanel/InfoPanelHeader.vue'
import InfoTabs from '@/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/workbench/extensions/manager/components/manager/infoPanel/MetadataRow.vue'
import { usePackUpdateStatus } from '@/workbench/extensions/manager/composables/nodePack/usePackUpdateStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
@@ -99,6 +109,8 @@ whenever(isInstalled, () => {
isInstalling.value = false
})
const { canTryNightlyUpdate } = usePackUpdateStatus(() => nodePack)
const { checkNodeCompatibility } = useConflictDetection()
const { getConflictsForPackageByID } = useConflictDetectionStore()

View File

@@ -18,12 +18,24 @@
<div v-if="isMixed" class="text-sm text-neutral-500">
{{ $t('manager.mixedSelectionMessage') }}
</div>
<!-- All installed: Show uninstall button -->
<PackUninstallButton
<!-- All installed: Show update (if nightly) and uninstall buttons -->
<div
v-else-if="isAllInstalled"
size="md"
:node-packs="installedPacks"
/>
class="flex w-full justify-center gap-2"
>
<Button
v-if="hasNightlyPacks"
v-tooltip.top="$t('manager.tryUpdateTooltip')"
variant="textonly"
size="md"
:disabled="isUpdatingSelected"
@click="updateSelectedNightlyPacks"
>
<DotSpinner v-if="isUpdatingSelected" duration="1s" :size="16" />
<span>{{ updateSelectedLabel }}</span>
</Button>
<PackUninstallButton size="md" :node-packs="installedPacks" />
</div>
<!-- None installed: Show install button -->
<PackInstallButton
v-else-if="isNoneInstalled"
@@ -55,8 +67,11 @@
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { computed, onUnmounted, provide, toRef } from 'vue'
import { computed, onUnmounted, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
import PackStatusMessage from '@/workbench/extensions/manager/components/manager/PackStatusMessage.vue'
@@ -68,6 +83,7 @@ import PackIconStacked from '@/workbench/extensions/manager/components/manager/p
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { ConflictDetail } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { ImportFailedKey } from '@/workbench/extensions/manager/types/importFailedTypes'
@@ -75,6 +91,8 @@ const { nodePacks } = defineProps<{
nodePacks: components['schemas']['Node'][]
}>()
const { t } = useI18n()
const managerStore = useComfyManagerStore()
const nodePacksRef = toRef(() => nodePacks)
// Use new composables for cleaner code
@@ -83,11 +101,40 @@ const {
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed
isMixed,
nightlyPacks,
hasNightlyPacks
} = usePacksSelection(nodePacksRef)
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacksRef)
// Batch update state for nightly packs
const isUpdatingSelected = ref(false)
async function updateSelectedNightlyPacks() {
if (nightlyPacks.value.length === 0) return
isUpdatingSelected.value = true
try {
for (const pack of nightlyPacks.value) {
if (!pack.id) continue
await managerStore.updatePack.call({
id: pack.id,
version: 'nightly'
})
}
managerStore.updatePack.clear()
} catch (error) {
console.error('Batch nightly update failed:', error)
} finally {
isUpdatingSelected.value = false
}
}
const updateSelectedLabel = computed(() =>
isUpdatingSelected.value ? t('g.updating') : t('manager.updateSelected')
)
const { checkNodeCompatibility } = useConflictDetection()
const { getNodeDefs } = useComfyRegistryStore()

View File

@@ -6,7 +6,13 @@
:key="createNodeDefKey(nodeDef)"
class="rounded-lg border p-4"
>
<NodePreview :node-def="nodeDef" class="min-w-full! text-[.625rem]!" />
<div class="[zoom:0.6]">
<NodePreview
:node-def="nodeDef"
position="relative"
class="min-w-full! text-[.625rem]!"
/>
</div>
</div>
</template>
<template v-else-if="isLoading">

View File

@@ -1,19 +1,26 @@
import { toValue } from '@vueuse/core'
import { compare, valid } from 'semver'
import type { MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
export const usePackUpdateStatus = (
nodePack: components['schemas']['Node']
nodePackSource: MaybeRefOrGetter<components['schemas']['Node']>
) => {
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
// Use toValue to unwrap the source reactively inside computeds
const nodePack = computed(() => toValue(nodePackSource))
const isInstalled = computed(() => isPackInstalled(nodePack.value?.id))
const isEnabled = computed(() => isPackEnabled(nodePack.value?.id))
const installedVersion = computed(() =>
getInstalledPackVersion(nodePack.id ?? '')
getInstalledPackVersion(nodePack.value?.id ?? '')
)
const latestVersion = computed(() => nodePack.latest_version?.version)
const latestVersion = computed(() => nodePack.value?.latest_version?.version)
const isNightlyPack = computed(
() => !!installedVersion.value && !valid(installedVersion.value)
@@ -31,9 +38,19 @@ export const usePackUpdateStatus = (
return compare(latestVersion.value, installedVersion.value) > 0
})
/**
* Nightly packs can always "try update" since we cannot compare git hashes
* to determine if an update is actually available. This allows users to
* pull the latest changes from the repository.
*/
const canTryNightlyUpdate = computed(
() => isInstalled.value && isEnabled.value && isNightlyPack.value
)
return {
isUpdateAvailable,
isNightlyPack,
canTryNightlyUpdate,
installedVersion,
latestVersion
}

View File

@@ -1,3 +1,4 @@
import { valid } from 'semver'
import { computed } from 'vue'
import type { Ref } from 'vue'
@@ -41,12 +42,30 @@ export function usePacksSelection(nodePacks: Ref<NodePack[]>) {
return 'mixed'
})
/**
* Nightly packs are installed packs with a non-semver version (git hash)
* that are also enabled
*/
const nightlyPacks = computed(() =>
installedPacks.value.filter((pack) => {
if (!pack.id) return false
const version = managerStore.getInstalledPackVersion(pack.id)
const isNightly = !!version && !valid(version)
const isEnabled = managerStore.isPackEnabled(pack.id)
return isNightly && isEnabled
})
)
const hasNightlyPacks = computed(() => nightlyPacks.value.length > 0)
return {
installedPacks,
notInstalledPacks,
isAllInstalled,
isNoneInstalled,
isMixed,
selectionState
selectionState,
nightlyPacks,
hasNightlyPacks
}
}

View File

@@ -1414,16 +1414,22 @@ describe('useNodePricing', () => {
'duration'
])
expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([
'model_version',
'quad',
'style',
'texture',
'texture_quality'
'pbr',
'texture_quality',
'geometry_quality'
])
})
})
@@ -1507,6 +1513,7 @@ describe('useNodePricing', () => {
it('should return v2.5 standard pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: false },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1520,6 +1527,7 @@ describe('useNodePricing', () => {
it('should return v2.5 detailed pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.5' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1527,12 +1535,13 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.30/Run') // any style, quad, no texture, detailed
})
it('should return v2.0 detailed pricing for TripoImageToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoImageToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'any style' },
{ name: 'texture', value: false },
@@ -1540,12 +1549,13 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed
expect(price).toBe('$0.40/Run') // any style, quad, no texture, detailed
})
it('should return legacy pricing for TripoTextToModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1561,7 +1571,7 @@ describe('useNodePricing', () => {
const node = createMockNode('TripoRefineNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
expect(price).toBe('$0.30/Run')
})
it('should return fallback for TripoTextToModelNode without model', () => {
@@ -1570,7 +1580,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1592,24 +1602,39 @@ describe('useNodePricing', () => {
// Test different parameter combinations
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.10/Run' },
{
model_version: 'v3.0',
quad: false,
style: 'none',
texture: false,
expected: '$0.10/Run'
},
{
model_version: 'v3.0',
quad: false,
style: 'any style',
texture: false,
expected: '$0.15/Run'
},
{ quad: true, style: 'none', texture: false, expected: '$0.20/Run' },
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: false,
expected: '$0.25/Run'
expected: '$0.20/Run'
},
{
model_version: 'v3.0',
quad: true,
style: 'any style',
texture: true,
expected: '$0.30/Run'
}
]
testCases.forEach(({ quad, style, texture, expected }) => {
const node = createMockNode('TripoTextToModelNode', [
{ name: 'model_version', value: 'v2.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture },
@@ -1619,17 +1644,9 @@ describe('useNodePricing', () => {
})
})
it('should return static price for TripoConvertModelNode', () => {
it('should return static price for TripoRetargetNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoConvertModelNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
})
it('should return static price for TripoRetargetRiggedModelNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('TripoRetargetRiggedModelNode')
const node = createMockNode('TripoRetargetNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.10/Run')
@@ -1640,6 +1657,7 @@ describe('useNodePricing', () => {
// Test basic case - no style, no quad, no texture
const basicNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: false },
{ name: 'style', value: 'none' },
{ name: 'texture', value: false },
@@ -1649,6 +1667,7 @@ describe('useNodePricing', () => {
// Test high-end case - any style, quad, texture, detailed
const highEndNode = createMockNode('TripoMultiviewToModelNode', [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: true },
{ name: 'style', value: 'stylized' },
{ name: 'texture', value: true },
@@ -1663,7 +1682,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
})
@@ -1870,7 +1889,7 @@ describe('useNodePricing', () => {
const testCases = [
{ quad: false, style: 'none', texture: false, expected: '$0.20/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.25/Run' },
{ quad: false, style: 'none', texture: true, expected: '$0.30/Run' },
{
quad: true,
style: 'any style',
@@ -1879,9 +1898,9 @@ describe('useNodePricing', () => {
expected: '$0.50/Run'
},
{
quad: true,
quad: false,
style: 'any style',
texture: false,
texture: true,
textureQuality: 'standard',
expected: '$0.35/Run'
}
@@ -1890,6 +1909,7 @@ describe('useNodePricing', () => {
testCases.forEach(
({ quad, style, texture, textureQuality, expected }) => {
const widgets = [
{ name: 'model_version', value: 'v3.0' },
{ name: 'quad', value: quad },
{ name: 'style', value: style },
{ name: 'texture', value: texture }
@@ -1909,7 +1929,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.2-0.5/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1919,7 +1939,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})
@@ -1931,7 +1951,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.1-0.4/Run (varies with quad, style, texture & quality)'
'$0.1-0.65/Run (varies with quad, style, texture & quality)'
)
})

View File

@@ -60,7 +60,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: { width: 200, height: 100 },
size: computed(() => ({ width: 200, height: 100 })),
zIndex: 0,
startDrag: vi.fn(),
handleDrag: vi.fn(),
@@ -201,4 +201,32 @@ describe('LGraphNode', () => {
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe('')
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe(
'100px'
)
})
it('should initialize height CSS vars for expanded nodes', () => {
const wrapper = mountLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: false }
}
})
expect(wrapper.element.style.getPropertyValue('--node-height')).toBe(
'100px'
)
expect(wrapper.element.style.getPropertyValue('--node-height-x')).toBe('')
})
})

View File

@@ -1,238 +0,0 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)
describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with RANDOMIZED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode } = useStepperControl(modelValue, options)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
})
it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
expect(typeof applyControl).toBe('function')
})
})
describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(modelValue.value).toBe(100)
})
it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(105)
})
it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(95)
})
it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})
it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})
it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)
// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
})
describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})
it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})
describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(onChange).toHaveBeenCalledWith(101)
})
it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101)
})
it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.FIXED
applyControl()
expect(onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,163 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry
beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})
describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
})
it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})
it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
registry.register(control1, callback1)
registry.register(control2, callback2)
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')
expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})
describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).not.toHaveBeenCalled()
})
it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
mockGetSetting.mockReturnValue('before')
registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')
expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})
it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')
expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})
describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})

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