Compare commits

...

36 Commits

Author SHA1 Message Date
Comfy Org PR Bot
a032e50721 1.39.2 (#8447)
Patch version increment to 1.39.2

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-01-29 16:12:49 -08:00
Christian Byrne
d9ea36a1d0 refactor: change asset cache from nodeType-keyed to category-keyed (#8433)
## Summary

Refactors the model assets cache in `assetsStore.ts` to be keyed by
category (e.g., 'checkpoints', 'loras') instead of nodeType (e.g.,
'CheckpointLoaderSimple').

## Changes

- Rename `modelStateByKey` to `modelStateByCategory`
- Add `resolveCategory()` helper to translate nodeType to category for
cache lookup
- Multiple node types sharing the same category now share one cache
entry
- Add `invalidateCategory()` method for cache invalidation
- Maintain backwards-compatible public API accepting nodeType
- Update tests for new category-keyed behavior

## Benefits

1. **Deduplication**: Same category = same cache entry = single API call
2. **Simple invalidation**: Delete asset with tag 'checkpoints' then
invalidate cache
3. **Cleaner mental model**: Store to View reactive flow works naturally

## Testing

- All existing tests pass with updates
- Added new tests for category-keyed cache sharing, invalidateCategory,
and unknown node type handling

## Part of Stack

This is **PR 1 of 2** in a stacked PR series:
1. **This PR**: Refactor asset cache to category-keyed (architectural
improvement)
2. **[PR 2
#8434](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8434)**: Fix
deletion invalidation

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8433-refactor-change-asset-cache-from-nodeType-keyed-to-category-keyed-2f76d73d365081999b7fda12c9706ab5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-29 16:03:29 -08:00
AustinMroz
eceed972f5 Fix Help Center display in linear mode (#8438)
The Help Center popover wasn't working in linear mode because the
`#graph-canvas-container` is hidden. This is fixed by instead setting
the target to body. This matches the [default behaviour used by portals
in
rekai-ui](https://github.com/unovue/reka-ui/blob/v2/packages/core/src/Teleport/Teleport.vue)
<img width="476" height="677" alt="image"
src="https://github.com/user-attachments/assets/cca46648-3bce-4b72-a5be-3727e2358217"
/>

I've got a working branch for moving the Help Center to our reka-ui
Popover component, but that'll require a much larger surface area of
code changes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8438-Fix-Help-Center-display-in-linear-mode-2f76d73d36508112abedf1160f7c3a90)
by [Unito](https://www.unito.io)
2026-01-29 16:01:38 -08:00
Christian Byrne
72a6af4b9e make new queue panel disabled by default (#8444)
## Summary

Was enabled by default for 1.38 during nightly, but should be reverted
back now.

In the future we can now just use `isNightly` flag for this (ref:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8149)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8444-make-new-queue-panel-disabled-by-default-2f76d73d365081139b54f76d9325101d)
by [Unito](https://www.unito.io)
2026-01-29 23:56:54 +00:00
Rizumu Ayaka
fc38f16543 fix: default image input for the template is displayed as empty on dropdown selection (#8276)
The image input for nodes loaded from templates appears empty in the
Properties Panel.

When the widget's current value (saved in the template) is not in the
available file list returned by the server, the selectedSet is empty,
causing a placeholder to be displayed instead of the actual value.

Added a missingValueItem computed property in WidgetSelectDropdown.vue.
When the current value is not in inputItems or outputItems, it creates a
fallback item and adds it to dropdownItems.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8276-fix-default-image-input-for-the-template-is-displayed-as-empty-on-dropdown-selection-2f16d73d3650817eaad5e4e33637fb74)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 14:19:52 -08:00
Christian Byrne
faad2c03de feat: increase allowed batch count (on Run button) on cloud (from 4 to 32) (#8436)
## Summary

cloud.comfy.org now suports up to 100 queued jobs at a time
([details](https://x.com/ComfyUI/status/2016622139722572032?s=20)). We
can increase the batch count limit to 32. Possible downside is cloud
having to reject larger number of jobs over the 100 limit if someone go
to 32 and clicks 4+ times. This setting was configurable anyway before,
so this is mostly a QoL change.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8436-feat-increase-allowed-batch-count-on-Run-button-on-cloud-from-4-to-32-2f76d73d365081728650fabefc394046)
by [Unito](https://www.unito.io)
2026-01-29 13:54:52 -08:00
Christian Byrne
d4cec49db5 fix: use merge-multiple for snapshot artifact download (#8432)
## Summary

Fixes the snapshot merge failure introduced by PR #8377
(actions/download-artifact v4→v7 upgrade).

## Root Cause

The v5+ release of `download-artifact` changed behavior: when a
`pattern` matches only a **single artifact**, files are extracted
directly to `path/` without the artifact name subdirectory. When only
one shard had changes, the merge loop couldn't find the expected
`snapshots-shard-*/` directories.

## Fix

Use `merge-multiple: true` — the documented pattern for combining
sharded artifacts. This merges all matched artifacts directly into the
target path, eliminating directory structure assumptions.

## Testing

This fix can be validated by re-running the workflow on [PR
#8276](https://github.com/Comfy-Org/ComfyUI_frontend/pull/8276) after
merge.

---
- Fixes snapshot update workflow regression from #8377

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8432-fix-use-merge-multiple-for-snapshot-artifact-download-2f76d73d3650810b97fdfe28cd3c7694)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 12:58:22 -08:00
AustinMroz
af8433fb3d Support widget specific contextmenu options in vue (#8431)
<img width="614" height="485" alt="image"
src="https://github.com/user-attachments/assets/2a635dec-8bed-4fab-9881-5e6057d482e1"
/>

These options were defined in `litegraphService`. While the existing
code for defining options is reused (to ensure there's no implementation
drift) these extra widget options use the litegraph format for context
menu options and do not belong in `useSelectionMenuOptions`. They have
been moved out of `useLitegraphService` (good), but left in
`litegraphService` (not great)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8431-Support-widget-specific-contextmenu-options-in-vue-2f76d73d3650814fb20fca352dc81e3b)
by [Unito](https://www.unito.io)
2026-01-29 11:38:41 -08:00
Johnpaul Chiwetelu
cabd08f0ec Road to No explicit any: Group 8 (part 6) test files (#8344)
## Summary

This PR removes unsafe type assertions ("as unknown as Type") from test
files and improves type safety across the codebase.

### Key Changes

#### Type Safety Improvements
- Removed all instances of "as unknown as" patterns from test files
- Used proper factory functions from litegraphTestUtils instead of
custom mocks
- Made incomplete mocks explicit using Partial<T> types
- Fixed DialogStore mocking with proper interface exports
- Improved type safety with satisfies operator where applicable

#### App Parameter Removal
- **Removed the unused `app` parameter from all ComfyExtension interface
methods**
- The app parameter was always undefined at runtime as it was never
passed from invokeExtensions
- Affected methods: init, setup, addCustomNodeDefs,
beforeRegisterNodeDef, beforeRegisterVueAppNodeDefs,
registerCustomNodes, loadedGraphNode, nodeCreated, beforeConfigureGraph,
afterConfigureGraph

##### Breaking Change Analysis
Verified via Sourcegraph that this is NOT a breaking change:
- Searched all 10 affected methods across GitHub repositories
- Only one external repository
([drawthingsai/draw-things-comfyui](https://github.com/drawthingsai/draw-things-comfyui))
declares the app parameter in their extension methods
- That repository never actually uses the app parameter (just declares
it in the function signature)
- All other repositories already omit the app parameter
- Search queries used:
- [init method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22init%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
- [setup method
search](https://sourcegraph.com/search?q=context:global+repo:%5Egithub%5C.com/.*+lang:typescript+%22setup%28app%22+-repo:Comfy-Org/ComfyUI_frontend&patternType=standard)
  - Similar searches for all 10 methods confirmed no usage

### Files Changed

Test files:
-
src/components/settings/widgets/__tests__/WidgetInputNumberInput.test.ts
- src/services/keybindingService.escape.test.ts  
- src/services/keybindingService.forwarding.test.ts
- src/utils/__tests__/newUserService.test.ts →
src/utils/__tests__/useNewUserService.test.ts
- src/services/jobOutputCache.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
-
src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts

Source files:
- src/types/comfy.ts - Removed app parameter from ComfyExtension
interface
- src/services/extensionService.ts - Improved type safety with
FunctionPropertyNames helper
- src/scripts/metadata/isobmff.ts - Fixed extractJson return type per
review
- src/extensions/core/*.ts - Updated extension implementations
- src/scripts/app.ts - Updated app initialization

### Testing
- All existing tests pass
- Type checking passes  
- ESLint/oxlint checks pass
- No breaking changes for external repositories

Part of the "Road to No Explicit Any" initiative.

### Previous PRs in this series:
- Part 2: #7401
- Part 3: #7935
- Part 4: #7970
- Part 5: #8064
- Part 6: #8083
- Part 7: #8092
- Part 8 Group 1: #8253
- Part 8 Group 2: #8258
- Part 8 Group 3: #8304
- Part 8 Group 4: #8314
- Part 8 Group 5: #8329
- Part 8 Group 6: #8344 (this PR)
2026-01-29 11:03:17 -08:00
AustinMroz
868180eb28 Disable logs button in sidebar on cloud (#8429)
Since cloud doesn't currently provide logs, this button was just causing
confusion.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8429-Disable-logs-button-in-sidebar-on-cloud-2f76d73d365081a4b909dd9a105e381e)
by [Unito](https://www.unito.io)
2026-01-29 09:58:25 -08:00
Alexander Brown
c51916d103 Chore: Add workflow dispatch to E2E (#8422)
Also remove old branches from the ignore

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8422-Chore-Add-workflow-dispatch-to-E2E-2f76d73d365081d0940dceaa37231ca7)
by [Unito](https://www.unito.io)
2026-01-29 08:47:18 -08:00
Comfy Org PR Bot
d2ff7d518a 1.39.1 (#8382)
Patch version increment to 1.39.1

**Base branch:** `main`

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

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-29 00:31:49 -08:00
AustinMroz
44baadd7ca Implement clickable badges (#8401)
Adds an `onClick` handler to LGraphBadge

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8401-Implement-clickable-badges-2f76d73d365081b3b23fc1eaa3bc65b8)
by [Unito](https://www.unito.io)
2026-01-29 00:04:28 -08:00
AustinMroz
3866fe7eaa Fix flake hidream test (#8406)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8406-Fix-flake-hidream-test-2f76d73d3650814daae8fb7775fa3073)
by [Unito](https://www.unito.io)
2026-01-29 00:02:30 -08:00
Jin Yi
4debbf8268 [bugfix] Disable install button when already installed version is selected (#8412)
## Summary
Prevent re-installing an already installed version by disabling the
Install button and the version option in the selector.

## Changes
- Add `isVersionInstalled()` function to check if a version is already
installed
- Add `isInstallDisabled` computed to disable Install button when
selected version is installed
- Add `option-disabled="isInstalled"` to Listbox to prevent selecting
installed versions

Fixes the issue where users could trigger duplicate install operations
by selecting the currently installed version.

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8412-bugfix-Disable-install-button-when-already-installed-version-is-selected-2f76d73d365081cb859ff98a3d1c64e6)
by [Unito](https://www.unito.io)
2026-01-28 23:58:12 -08:00
Christian Byrne
23a5baef43 feat: add category support for blueprints and protect global blueprints (#8378)
## Summary

This PR adds two related features for subgraph blueprints:

### 1. Protect Global Blueprints from Deletion
- Added `isGlobalBlueprint()` helper that distinguishes blueprints by
`python_module` field
- User blueprints have `python_module: 'blueprint'`
- Global (installed) blueprints have `python_module` set to the node
pack name
- Guarded `deleteBlueprint()` to show a warning toast for global
blueprints
- Hidden delete menu in node library for global blueprints

### 2. Category Support for Blueprints
- User blueprints now use category `Subgraph Blueprints/User`
- Global blueprints use `Subgraph Blueprints/{category}` if category is
provided, otherwise `Subgraph Blueprints`
- Extended `GlobalSubgraphData.info` type with optional `category` field
- Added `category` to `zSubgraphDefinition` schema

## Files Changed
- `src/stores/subgraphStore.ts` - Core logic for both features
- `src/stores/subgraphStore.test.ts` - Tests for `isGlobalBlueprint`
- `src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue` - Hide
delete menu for global blueprints
- `src/scripts/api.ts` - Extended `GlobalSubgraphData` type
- `src/platform/workflow/validation/schemas/workflowSchema.ts` - Added
category to schema
- `src/locales/en/main.json` - Added translation key

## Testing
-  `pnpm typecheck` passed
-  `pnpm lint` passed

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8378-feat-add-category-support-for-blueprints-and-protect-global-blueprints-2f66d73d36508137aa67c2d88c358b69)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-28 23:30:48 -08:00
Christian Byrne
0faf2220b8 fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8413)
## Summary

When attempting to select text inside Vue node Markdown widget's
textarea (edit mode), the node would drag instead of text being
selected.

Root cause: WidgetMarkdown.vue's Textarea only had @click.stop and
@keydown.stop, but was missing pointer event modifiers. The pointerdown
event bubbled up to LGraphNode.vue which initiated node drag.

*Fix*: Add @pointerdown.capture.stop, @pointermove.capture.stop, and
@pointerup.capture.stop to match the pattern used in WidgetTextarea.vue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8413-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-node-to-drag-2f76d73d3650816dbf9bdf893775c3d4)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 23:18:23 -08:00
Jin Yi
65ff23c5af [bugfix] Fix manager missing node tab with shared composable (#8409) 2026-01-29 06:23:47 +00:00
Alexander Brown
6ce60a11a4 test: use createTestingPinia instead of createPinia (#8376)
Replace \createPinia\ with \createTestingPinia({ stubActions: false })\
from \@pinia/testing\ across 45 test files for proper test isolation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8376-test-use-createTestingPinia-instead-of-createPinia-2f66d73d36508137a9f0daffcddc86f7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 22:21:38 -08:00
Christian Byrne
3b5d124029 fix: use getAuthHeader in createCustomer to support API key auth (#8408)
## Summary

Fixes authentication failure when using API key authentication on
staging server after frontend update to 1.33.10.

<img width="1160" height="709" alt="image"
src="https://github.com/user-attachments/assets/fe56866d-1819-419e-9f53-35a123d764c3"
/>


## Changes

- **What**: Changed `createCustomer()` to use `getAuthHeader()` instead
of `getFirebaseAuthHeader()`, allowing API key users to authenticate
successfully

## Review Focus

- Verify `getAuthHeader()` correctly falls back to API key when no
Firebase token exists
- Backend `/customers` endpoint supports `X-API-KEY` header (per cloud
PR #1766)

Fixes COM-12398

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8408-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d3650819994e3e6d3ed9f3dfa)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 22:12:28 -08:00
Alexander Brown
bd4920febc Chore: Actions updates and cleanup (#8377)
## Summary

...

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8377-WIP-Chore-Actions-updates-and-cleanup-2f66d73d3650818483a8dffa32a6f245)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 21:22:39 -08:00
Jin Yi
bd916096ac fix: add null check in getCanvasCenter to prevent crash on asset insert (#8399)
## Summary
Adds null check in `getCanvasCenter()` to prevent crash when inserting
asset as node before canvas is fully initialized.

## Changes
- **What**: Added optional chaining for `app.canvas?.ds?.visible_area`
with fallback to `[0, 0]`

## Review Focus
- Simple defensive fix - returns origin position if canvas not ready

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

Co-Authored-By: Claude <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8399-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-insert-2f76d73d365081e88c08ef40ea9e7b78)
by [Unito](https://www.unito.io)
2026-01-29 13:19:51 +09:00
guill
9be853f6b5 feat: support dev-only nodes (#8359)
## Summary

Support `dev_only` property to node definitions that hides nodes from
search and menus unless dev mode is enabled. Dev-only nodes display a
"DEV" badge when visible.

This functionality is primarily intended to support unit-testing nodes
on Comfy Cloud, but also has other uses.

## Changes

- **What**: Nodes flagged as dev_only in the node schema will only
appear in search and menus if Dev Mode is on.

## Screenshots (if applicable)

With Dev Mode off:
<img width="2189" height="1003" alt="image"
src="https://github.com/user-attachments/assets/a08e1fd7-dca9-4ce1-9964-5f4f3b7b95ac"
/>

With Dev Mode on:
<img width="2201" height="1066" alt="image"
src="https://github.com/user-attachments/assets/7fe6cd1f-f774-4f48-b604-a528e286b584"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8359-feat-support-dev-only-nodes-2f66d73d36508102839ee7cd66a26129)
by [Unito](https://www.unito.io)
2026-01-28 19:41:45 -08:00
Christian Byrne
2103dcc788 fix: increase Vue node resize handle size for better usability (#8391)
## Summary

Increases the resize handle size on Vue nodes to improve usability,
especially when nodes are selected.

## Changes

- **What**: Increased resize handle from 12px to 20px and offset it
slightly outside the node boundary to avoid overlap with selection
outline

## Review Focus

The resize handle was too small and became harder to grab when the node
was selected (the 2px outline rendered outside the box, visually
obscuring the corner). This fix increases the hit area and positions it
to extend beyond the node edge.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8391-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d36508136b2aac51bc0d53551)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 19:15:40 -08:00
Simula_r
fe7d89d1b1 fix: move WorkspaceAuthGate to LayoutDefault for proper re-login hand… (#8381)
## Summary

- Move WorkspaceAuthGate from App.vue to LayoutDefault.vue so it only
wraps authenticated routes
- Change initialize() to run in onMounted() for proper Vue lifecycle
- Restore immediate: true in cloudRemoteConfig watcher as backup
- Gate now mounts fresh after login, fixing re-login feature flag issue

The root cause was a race condition: after logout + page reload, the
cloudRemoteConfig watcher could be set up after the user already logged
in, missing the isLoggedIn change and never calling /features endpoint.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8381-fix-move-WorkspaceAuthGate-to-LayoutDefault-for-proper-re-login-hand-2f66d73d36508182a3dec09a49214a00)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 18:33:44 -08:00
pythongosssss
e4f43d5cc4 Add color picker widget using native HTML5 input element (#8384)
## Summary

Adds support for the color picker widget when using Litegraph nodes, it
is already supported in Nodes 2.0

## Changes

- **What**: Add custom drawing of color picker widget using HTML 5
native color input element
- This enables us to add a core node using the COLOR type that works on
both legacy and Nodes 2.0

## Screenshots (if applicable)
Chrome Windows:
<img width="743" height="493" alt="image"
src="https://github.com/user-attachments/assets/b2e421e0-3a1e-4b72-8856-ae5e40ac0661"
/>

Firefox Windows:
<img width="606" height="447" alt="image"
src="https://github.com/user-attachments/assets/27db5552-6ba2-4de0-af26-ea1727808b4b"
/>

Nodes 2.0 (unchanged):
<img width="597" height="258" alt="image"
src="https://github.com/user-attachments/assets/8bfcf408-e11b-481e-b78f-208b4db80f05"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8384-Add-color-picker-widget-using-native-HTML5-input-element-2f76d73d365081c69fe2f39f01fff539)
by [Unito](https://www.unito.io)
2026-01-28 17:55:12 -08:00
AustinMroz
d5e9be6a64 Additional linear tweaks (#8375)
- Textareas have a fixed (larger) height. This was requested by design
and I thought I fixed it while back.
  | Before | After |
  | ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/35ecfa42-4812-43b3-9844-4ef1f757ae40"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/8d13e114-6524-4f3e-ae61-0d31d754466c"
/>|
- Since the typeform survey popover doesn't work well on mobile, the
button will instead open the survey in a new tab for mobile users.
- Workaround for the first linear output on local not being
automatically selected for display
- Add the linear mode toggle to the bottom left of mobile layout
<img width="634" height="90" alt="image"
src="https://github.com/user-attachments/assets/571c672c-5913-4dc9-84f9-d16c49b4a587"
/>
- Adds a hamburger menu to the mobile layout providing buttons for
opening workflows, templates, and an additional exit linear option.
- Button takes up a full line of space, so it doesn't provide much space
savings currently. May need some design iteration.
<img width="635" height="225" alt="image"
src="https://github.com/user-attachments/assets/a305e795-db0d-4265-b64b-04326a69216d"
/>

And an unrelated tweak requested by Comfy: when opening templates, the
searchbar is autofocused.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8375-Additional-linear-tweaks-2f66d73d36508172a5e7e716d0cba873)
by [Unito](https://www.unito.io)
2026-01-28 15:22:41 -08:00
Alexander Brown
8aca2ed197 feat: Change the card description to the filename (#8348)
## Summary

No longer duplicates the badge info for the Model type.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8348-feat-Change-the-card-description-to-the-filename-2f66d73d3650818d99e1de479d1f8486)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 14:25:07 -08:00
Benjamin Lu
cbd073f89d Add inline queue progress bar and text summary (#8271)
Add inline queue progress bar and summary per the new designs.

This adds an inline 3px progress bar in the actionbar container (docked
or floating) and a compact summary line below the top menu that follows
when floating, both gated by the QPO V2 flag and hidden while the
overlay is expanded.


https://github.com/user-attachments/assets/da8ec7b7-35f4-4d52-a83b-15c21b484eba

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8271-Add-inline-queue-progress-bar-and-summary-for-QPO-V2-2f16d73d36508132a6dff247f71e11e4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-01-28 12:20:13 -08:00
Alexander Brown
e44b411ff6 test: simplify test file mocking patterns (#8320)
Simplifies test mocking patterns across multiple test files.

- Removes redundant `vi.hoisted()` calls
- Cleans up mock implementations
- Removes unused imports and variables

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8320-test-simplify-test-file-mocking-patterns-2f46d73d36508150981bd8ecb99a6a11)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 12:17:16 -08:00
Terry Jia
3e2352423b fix: remove redundant forceRender call and add ResizeObserver guard (#8372)
## Summary
- Remove duplicate forceRender() in ResizeObserver callback since
handleResize() already calls it
- Add guard for environments without ResizeObserver support
- Disconnect existing observer before reassigning to prevent leaks

requested by @DrJKL in
https://github.com/Comfy-Org/ComfyUI_frontend/pull/8351

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8372-fix-remove-redundant-forceRender-call-and-add-ResizeObserver-guard-2f66d73d3650811bb3a6de5c59b3c1fb)
by [Unito](https://www.unito.io)
2026-01-28 11:51:40 -08:00
Christian Byrne
3720b3e794 feat: make invalid URL error message more actionable (#8368)
## Summary

Updates the error message shown when users enter an unsupported URL in
the BYOM (Bring Your Own Model) upload dialog.

**Before:** "Only URLs from Civitai, Hugging Face are supported"
**After:** "Please check the link format. Only URLs from Civitai,
Hugging Face are supported."

This provides more actionable guidance by suggesting users verify their
link format before listing the supported sources.

## Changes

- Updated `unsupportedUrlSource` i18n key in `src/locales/en/main.json`

## Testing

- `pnpm typecheck` 
- `pnpm lint` 
- Manual: Enter invalid URL (e.g.,
`https://example.com/model.safetensors`) in model upload dialog

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8368-feat-make-invalid-URL-error-message-more-actionable-2f66d73d3650810bbcc1e9fa3d1cd962)
by [Unito](https://www.unito.io)

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-28 02:15:09 -08:00
Terry Jia
26eb3eff4d fix: add ResizeObserver to fix Preview3D initial render stretch (#8351)
## Summary

When Preview3D node was added to canvas, the Three.js scene would
stretch outside the node bounds until mouse hover. This happened because
the container size was not stable during initialization.

Add ResizeObserver to Load3d class to automatically refresh viewport
when container size changes, ensuring correct render dimensions.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8351-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f66d73d3650810cbd1fc64dde9ddc17)
by [Unito](https://www.unito.io)
2026-01-28 05:11:54 -05:00
Rizumu Ayaka
dd3e4d3edc fix: hide label of textarea in right side panel + align switch to the left (#8279)
<img width="350" alt="image"
src="https://github.com/user-attachments/assets/3ff3f83c-163b-44df-8b68-7fe18c3266c4"
/>
 | 
<img width="350" alt="CleanShot 2026-01-23 at 21 25 04@2x"
src="https://github.com/user-attachments/assets/c2c630f3-6990-4a55-aa8f-a19297ffee52"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8279-fix-hide-label-of-textarea-in-right-side-panel-align-switch-to-the-left-2f16d73d365081e9b932fb2be873b660)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 23:21:03 -08:00
Alexander Brown
8b514463b3 CI: Add formatting after generating locales. (#8360)
## Summary

Hopefully prevent thrashing from formatting disagreements.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8360-CI-Add-formatting-after-generating-locales-2f66d73d36508143a2f6d5ba90056110)
by [Unito](https://www.unito.io)
2026-01-27 22:31:54 -08:00
Comfy Org PR Bot
608ad1d74c 1.39.0 (#8336)
Minor version increment to 1.39.0

**Base branch:** `main`

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

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-01-27 21:56:40 -08:00
228 changed files with 4909 additions and 1391 deletions

1
.gitattributes vendored
View File

@@ -11,6 +11,7 @@
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
# Generated files
packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true

View File

@@ -104,14 +104,14 @@ runs:
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}

View File

@@ -16,7 +16,7 @@ runs:
# Checkout ComfyUI repo, install the dev_tools node and start server
- name: Checkout ComfyUI
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: 'comfyanonymous/ComfyUI'
path: 'ComfyUI'
@@ -33,7 +33,7 @@ runs:
fi
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.10'

View File

@@ -12,29 +12,17 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
cache-dependency-path: './pnpm-lock.yaml'
# Restore tool caches before running any build/lint operations
- name: Restore tool output cache
uses: actions/cache/restore@v4
with:
path: |
./.cache
./tsconfig.tsbuildinfo
key: tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-${{ hashFiles('./src/**/*.{ts,vue,js,mts}', './*.config.*') }}
restore-keys: |
tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-
tool-cache-${{ runner.os }}-
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile

View File

@@ -11,7 +11,7 @@ runs:
echo "playwright-version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
- name: Cache Playwright Browsers
uses: actions/cache@v4
uses: actions/cache@v5 # v5.0.2
id: cache-playwright-browsers
with:
path: '~/.cache/ms-playwright'

View File

@@ -13,15 +13,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -36,7 +36,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -18,15 +18,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -35,7 +35,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
@@ -86,7 +86,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'

View File

@@ -17,15 +17,15 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: 'pnpm'
@@ -34,7 +34,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Checkout comfy-api repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/comfy-api
path: comfy-api
@@ -87,7 +87,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -13,6 +13,6 @@ jobs:
json-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Validate JSON syntax
run: ./scripts/cicd/check-json.sh

View File

@@ -18,23 +18,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run ESLint with auto-fix
run: pnpm lint:fix
@@ -73,7 +62,7 @@ jobs:
- name: Comment on PR about auto-fix
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({
@@ -86,7 +75,7 @@ jobs:
- name: Comment on PR about manual fix needed
if: steps.verify-changed-files.outputs.changed == 'true' && github.event.pull_request.head.repo.full_name != github.repository
continue-on-error: true
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
github.rest.issues.createComment({

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'

View File

@@ -17,21 +17,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build project
run: pnpm build
@@ -46,7 +35,7 @@ jobs:
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: size-data
path: temp/size

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get PR Number
id: pr
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Reports
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -5,8 +5,8 @@ on:
push:
branches: [main, master, core/*, desktop/*]
pull_request:
branches-ignore:
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
branches-ignore: [wip/*, draft/*, temp/*]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
@@ -25,7 +25,7 @@ jobs:
# Upload only built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: frontend-dist
path: dist/
@@ -51,9 +51,9 @@ jobs:
shardTotal: [8]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -72,7 +72,7 @@ jobs:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
- name: Upload blob report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: blob-report-chromium-${{ matrix.shardIndex }}
@@ -98,9 +98,9 @@ jobs:
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -128,7 +128,7 @@ jobs:
pnpm exec playwright merge-reports --reporter=json ./blob-report
- name: Upload Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-${{ matrix.browser }}
@@ -141,16 +141,13 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Download blob reports
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: ./all-blob-reports
pattern: blob-report-chromium-*
@@ -165,7 +162,7 @@ jobs:
pnpm dlx @playwright/test merge-reports --reporter=json ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: playwright-report-chromium
path: ./playwright-report/
@@ -183,7 +180,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get start time
id: start-time
@@ -210,10 +207,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download all playwright reports
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
pattern: playwright-report-*
path: reports

View File

@@ -31,11 +31,11 @@ jobs:
echo "Is forked: ${{ github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}"
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Get PR Number
id: pr
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
@@ -68,7 +68,7 @@ jobs:
- name: Download and Deploy Storybook
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Post starting comment
env:
@@ -36,21 +36,10 @@ jobs:
workflow-url: ${{ steps.workflow-url.outputs.url }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
@@ -69,7 +58,7 @@ jobs:
- name: Upload Storybook build
if: success() && github.event.pull_request.head.repo.fork == false
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: storybook-static
path: storybook-static/
@@ -86,27 +75,16 @@ jobs:
chromatic-storybook-url: ${{ steps.chromatic.outputs.storybookUrl }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0 # Required for Chromatic baseline
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook and run Chromatic
id: chromatic
uses: chromaui/action@latest
uses: chromaui/action@07791f8243f4cb2698bf4d00426baf4b2d1cb7e0 # v13.3.5
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybook
@@ -136,11 +114,11 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download Storybook build
if: needs.storybook-build.outputs.conclusion == 'success'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: storybook-static
path: storybook-static
@@ -170,7 +148,7 @@ jobs:
pull-requests: write
steps:
- name: Update comment with Chromatic URLs
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const buildUrl = '${{ needs.chromatic-deployment.outputs.chromatic-build-url }}';

View File

@@ -16,21 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest tests
run: pnpm test:unit

View File

@@ -0,0 +1,21 @@
name: Validate Action SHA Pins
on:
pull_request:
paths:
- '.github/workflows/**'
- '.github/actions/**'
- '.pinact.yaml'
permissions:
contents: read
jobs:
validate-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: suzuki-shunsuke/pinact-action@3d49c6412901042473ffa78becddab1aea46bbea # v1.3.1
with:
skip_push: 'true'

View File

@@ -17,10 +17,10 @@ jobs:
yaml-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.x'

View File

@@ -18,12 +18,12 @@ jobs:
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment
- name: Setup ComfyUI Frontend
@@ -41,7 +41,7 @@ jobs:
env:
PLAYWRIGHT_TEST_URL: http://localhost:5173
- name: Update translations
run: pnpm locale
run: pnpm locale && pnpm format
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Commit updated locales

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment with custom node repository
- name: Setup ComfyUI Server (without launching)
@@ -36,7 +36,7 @@ jobs:
# Install the custom node repository
- name: Checkout custom node repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: ${{ inputs.owner }}/${{ inputs.repository }}
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
@@ -113,7 +113,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4 # v2.7.0
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Setup playwright environment
- name: Setup ComfyUI Server (and start)
uses: ./.github/actions/setup-comfyui-server
@@ -40,7 +40,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'Update locales for node definitions'

View File

@@ -64,7 +64,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0

View File

@@ -23,18 +23,18 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
@@ -44,7 +44,7 @@ jobs:
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
with:
label_trigger: 'claude-review'
prompt: |

View File

@@ -33,24 +33,13 @@ jobs:
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download size data
uses: dawidd6/action-download-artifact@v11
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
@@ -75,7 +64,7 @@ jobs:
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@v11
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: ci-size-data.yaml
@@ -89,12 +78,12 @@ jobs:
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@v1
uses: juliangruber/read-file-action@b549046febe0fe86f8cb4f93c24e284433f9ab58 # v1.1.7
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@v3
uses: actions-cool/maintain-one-comment@4b2dbf086015f892dcb5e8c1106f5fccd6c1476b # v3.2.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}

View File

@@ -38,7 +38,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Find Update Comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: 'find-update-comment'
with:
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -46,7 +46,7 @@ jobs:
body-includes: 'Updating Playwright Expectations'
- name: Add Starting Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
issue-number: ${{ steps.pr-info.outputs.pr-number }}
@@ -56,7 +56,7 @@ jobs:
reactions: eyes
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
- name: Setup frontend
@@ -66,7 +66,7 @@ jobs:
# Upload built dist/ (containerized test jobs will pnpm install without cache)
- name: Upload built frontend
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: frontend-dist
path: dist/
@@ -91,11 +91,11 @@ jobs:
shardTotal: [4]
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
- name: Download built frontend
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: frontend-dist
path: dist/
@@ -149,7 +149,7 @@ jobs:
# Upload ONLY the changed files from this shard
- name: Upload changed snapshots
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: steps.changed-snapshots.outputs.has-changes == 'true'
with:
name: snapshots-shard-${{ matrix.shardIndex }}
@@ -157,7 +157,7 @@ jobs:
retention-days: 1
- name: Upload test report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-shard-${{ matrix.shardIndex }}
@@ -170,17 +170,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ needs.setup.outputs.branch }}
# Download all changed snapshot files from shards
- name: Download snapshot artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
pattern: snapshots-shard-*
path: ./downloaded-snapshots
merge-multiple: false
merge-multiple: true
- name: List downloaded files
run: |
@@ -206,13 +206,13 @@ jobs:
echo "MERGING CHANGED SNAPSHOTS"
echo "=========================================="
# Check if any artifacts were downloaded
# Check if any artifacts were downloaded (merge-multiple puts files directly in path)
if [ ! -d "./downloaded-snapshots" ]; then
echo "No snapshot artifacts to merge"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: 0"
echo "Files merged: 0"
exit 0
fi
@@ -222,37 +222,29 @@ jobs:
exit 1
fi
merged_count=0
# Count files to merge
file_count=$(find ./downloaded-snapshots -type f | wc -l)
# For each shard's changed files, copy them directly
for shard_dir in ./downloaded-snapshots/snapshots-shard-*/; do
if [ ! -d "$shard_dir" ]; then
continue
fi
if [ "$file_count" -eq 0 ]; then
echo "No snapshot files found in downloaded artifacts"
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Files merged: 0"
exit 0
fi
shard_name=$(basename "$shard_dir")
file_count=$(find "$shard_dir" -type f | wc -l)
echo "Merging $file_count snapshot file(s)..."
if [ "$file_count" -eq 0 ]; then
echo " $shard_name: no files"
continue
fi
echo "Processing $shard_name ($file_count file(s))..."
# Copy files directly, preserving directory structure
# Since files are already in correct structure (no browser_tests/ prefix), just copy them all
cp -v -r "$shard_dir"* browser_tests/ 2>&1 | sed 's/^/ /'
merged_count=$((merged_count + 1))
echo " ✓ Merged"
echo ""
done
# Copy all files directly, preserving directory structure
# With merge-multiple: true, files are directly in ./downloaded-snapshots/ without shard subdirs
cp -v -r ./downloaded-snapshots/* browser_tests/ 2>&1 | sed 's/^/ /'
echo ""
echo "=========================================="
echo "MERGE COMPLETE"
echo "=========================================="
echo "Shards merged: $merged_count"
echo "Files merged: $file_count"
- name: Show changes
run: |
@@ -301,7 +293,7 @@ jobs:
echo "✓ Commit and push successful"
- name: Add Done Reaction
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: github.event_name == 'issue_comment' && steps.commit.outputs.has-changes == 'true'
with:
comment-id: ${{ needs.setup.outputs.comment-id }}

View File

@@ -20,13 +20,13 @@ jobs:
dist_tag: ${{ steps.dist.outputs.dist_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
@@ -71,7 +71,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -77,19 +77,19 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
cache: 'pnpm'

View File

@@ -61,13 +61,13 @@ jobs:
steps:
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
path: frontend
- name: Checkout ComfyUI (sparse)
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: Comfy-Org/ComfyUI
sparse-checkout: |
@@ -75,12 +75,12 @@ jobs:
path: comfyui
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
@@ -169,7 +169,7 @@ jobs:
steps:
- name: Checkout ComfyUI fork
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
repository: ${{ inputs.comfyui_fork || 'Comfy-Org/ComfyUI' }}
token: ${{ secrets.PR_GH_TOKEN }}

View File

@@ -18,13 +18,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.PR_GH_TOKEN || secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'

View File

@@ -19,12 +19,12 @@ jobs:
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -55,7 +55,7 @@ jobs:
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist-files
path: |
@@ -66,16 +66,13 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Create release
id: create_release
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -98,13 +95,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
@@ -119,8 +116,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -147,7 +143,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2

View File

@@ -69,18 +69,18 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.resolve_ref.outputs.ref }}
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'

View File

@@ -15,12 +15,12 @@ jobs:
version: ${{ steps.current_version.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'pnpm'
@@ -40,7 +40,7 @@ jobs:
pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: dist-files
path: |
@@ -52,13 +52,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Download dist artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: dist-files
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install build dependencies
@@ -73,7 +73,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -65,7 +65,7 @@ jobs:
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ github.token }}
script: |
@@ -118,7 +118,7 @@ jobs:
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ steps.prepared-inputs.outputs.branch }}
fetch-depth: 0
@@ -142,12 +142,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: lts/*
@@ -180,7 +180,7 @@ jobs:
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
@@ -51,12 +51,12 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '24.x'
cache: 'pnpm'
@@ -79,7 +79,7 @@ jobs:
echo "capitalised=${VERSION_TYPE@u}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Increment desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -22,18 +22,18 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-depth: 50
ref: main
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
@@ -49,7 +49,7 @@ jobs:
fi
- name: Run Claude Documentation Review
uses: anthropics/claude-code-action@v1.0.6
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38
with:
prompt: |
Is all documentation still 100% accurate?
@@ -130,7 +130,7 @@ jobs:
- name: Create or Update Pull Request
if: steps.check_changes.outputs.has_changes == 'true'
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'docs: weekly documentation accuracy update'

24
.pinact.yaml Normal file
View File

@@ -0,0 +1,24 @@
# pinact configuration
# https://github.com/suzuki-shunsuke/pinact
version: 3
files:
- pattern: .github/workflows/*.yaml
- pattern: .github/actions/**/*.yaml
# Actions that don't need SHA pinning (official GitHub actions are trusted)
ignore_actions:
- name: actions/cache
ref: v5
- name: actions/checkout
ref: v6
- name: actions/setup-node
ref: v6
- name: actions/setup-python
ref: v6
- name: actions/upload-artifact
ref: v6
- name: actions/download-artifact
ref: v7
- name: actions/github-script
ref: v8

View File

@@ -8,3 +8,6 @@ rules:
line-length: disable
document-start: disable
truthy: disable
comments:
min-spaces-from-content: 1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 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: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -18,7 +18,8 @@ Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/domains/workflow/ui/stores/workflowStore'
@@ -27,8 +28,8 @@ describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
// Create a fresh testing pinia and activate it for each test
setActivePinia(createTestingPinia({ stubActions: false }))
// Initialize the store
store = useWorkflowStore()

View File

@@ -11,6 +11,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
@@ -253,3 +254,79 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.38.12",
"version": "1.39.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -1,13 +1,11 @@
<template>
<WorkspaceAuthGate>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</WorkspaceAuthGate>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</template>
<script setup lang="ts">
@@ -16,7 +14,6 @@ import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -2,7 +2,8 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -14,6 +15,7 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -36,7 +38,17 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -55,18 +67,21 @@ function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
...stubs
},
directives: {
tooltip: () => {}
@@ -91,6 +106,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -151,7 +167,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
await nextTick()
@@ -169,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -185,7 +201,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -199,7 +215,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const wrapper = createWrapper({ pinia })
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -210,6 +226,84 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,101 +1,130 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex gap-x-0.5 pt-1"
class="ml-1 flex flex-col gap-1 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
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"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<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
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div
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"
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
</div>
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<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 />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -104,6 +133,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -147,6 +177,15 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -164,6 +203,19 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,6 +10,7 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -18,7 +19,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div ref="panelRef" class="flex items-center select-none gap-2">
<div class="relative flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -43,6 +44,14 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -51,14 +60,17 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -69,6 +81,15 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -76,15 +97,22 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<HTMLElement | null>(null)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
const { x, y, style, isDragging } = useDraggable(panelElement, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -101,11 +129,12 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -181,11 +210,12 @@ watch(
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const panel = panelElement.value
if (panel) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -252,6 +282,19 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -24,7 +24,7 @@
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
@@ -120,7 +120,10 @@ async function initializeWorkspaceMode(): Promise<void> {
}
}
// Start initialization immediately during component setup
// (not in onMounted, so initialization starts before DOM is ready)
void initialize()
// Initialize on mount. This gate should be placed on the authenticated layout
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
// The router guard ensures only authenticated users reach this layout.
onMounted(() => {
void initialize()
})
</script>

View File

@@ -14,7 +14,12 @@
</template>
<template #header>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
</template>
<template #header-right-area>

View File

@@ -149,7 +149,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { useNewUserService } from '@/services/useNewUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -457,11 +457,9 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
useNewUserService().initializeIfNewUser()
])
if (i18nError.value) {
console.warn(

View File

@@ -13,6 +13,8 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
@@ -289,9 +291,8 @@ describe('SelectionToolbox', () => {
)
})
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
@@ -307,9 +308,8 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
@@ -325,13 +325,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
// With output node selected
isOutputNodeSpy.mockReturnValue(true)

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -47,7 +48,7 @@ describe('ExecuteButton', () => {
}
})
beforeEach(async () => {
beforeEach(() => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
@@ -71,10 +72,7 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
vi.mocked(useSelectionState).mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"

View File

@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -0,0 +1,36 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,12 +8,14 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -146,9 +148,12 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -14,6 +14,8 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -52,7 +54,7 @@ const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide('hideLayoutField', true)
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
const { t } = useI18n()

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -15,6 +17,7 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -38,6 +41,7 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -59,7 +63,13 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
return sourceNode ? sourceNode.title || sourceNode.type : null
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -21,16 +21,17 @@
</div>
</div>
<div class="option-badges">
<Tag
v-if="nodeDef.experimental"
:value="$t('g.experimental')"
severity="primary"
/>
<Tag
v-if="nodeDef.deprecated"
:value="$t('g.deprecated')"
severity="danger"
/>
<Tag
v-if="nodeDef.experimental"
:value="$t('g.experimental')"
severity="primary"
/>
<Tag v-if="nodeDef.dev_only" :value="$t('g.devOnly')" severity="info" />
<Tag
v-if="showNodeFrequency && nodeFrequency > 0"
:value="formatNumberWithSuffix(nodeFrequency, { roundToInt: true })"

View File

@@ -41,7 +41,7 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
<SidebarBottomPanelToggleButton :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
@@ -65,6 +65,7 @@ import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPa
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'

View File

@@ -1,7 +1,11 @@
<template>
<div
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -35,6 +39,8 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -13,10 +13,7 @@
severity="danger"
/>
</template>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<template v-if="isUserBlueprint" #actions>
<Button
variant="destructive"
size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
if (!isUserBlueprint.value) return []
return [
{
label: t('g.delete'),
icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
if (!isUserBlueprint.value) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
void subgraphStore.deleteBlueprint(props.node.data.name)
}
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,6 +10,7 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -18,9 +19,20 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Popover>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12">
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -1,8 +1,9 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'
import {
@@ -45,6 +46,8 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidgetName = ref<string>()
/**
* Toggle the node options popover
* @param event - The trigger event
@@ -61,6 +64,13 @@ export function toggleNodeOptions(event: Event) {
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
hoveredWidgetName.value = undefined
const target = event.target
if (target instanceof HTMLElement) {
const widgetEl = target.closest('.lg-node-widget')
if (widgetEl instanceof HTMLElement)
hoveredWidgetName.value = widgetEl.dataset.widgetName
}
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
@@ -133,8 +143,8 @@ export function useMoreOptionsMenu() {
} = useGroupMenuOptions()
const {
getBasicSelectionOptions,
getSubgraphOptions,
getMultipleNodesOptions
getMultipleNodesOptions,
getSubgraphOptions
} = useSelectionMenuOptions()
const hasSubgraphs = hasSubgraphsComputed
@@ -164,13 +174,13 @@ export function useMoreOptionsMenu() {
// For single node selection, also get LiteGraph menu items to merge
const litegraphOptions: MenuOption[] = []
const node: LGraphNode | undefined = selectedNodes.value[0]
if (
selectedNodes.value.length === 1 &&
!groupContext &&
canvasStore.canvas
) {
try {
const node = selectedNodes.value[0]
const rawItems = canvasStore.canvas.getNodeMenuOptions(node)
// Don't apply structuring yet - we'll do it after merging with Vue options
litegraphOptions.push(
@@ -249,6 +259,18 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const rawName = hoveredWidgetName.value
const widget = node?.widgets?.find((w) => w.name === rawName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)
)
if (widgetOptions) {
options.push(...widgetOptions)
options.push({ type: 'divider' })
}
}
// Section 6 & 7: Extensions and Delete are handled by buildStructuredMenu
// Mark all Vue options with source

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,13 +185,11 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
get: vi.fn((key: string) => {
@@ -50,9 +51,6 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -105,6 +105,7 @@ export function addWidgetPromotionOptions(
content: `Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
promoteWidget(node, widget, promotableParents)
widget.callback?.(widget.value)
}
})
else {
@@ -112,6 +113,7 @@ export function addWidgetPromotionOptions(
content: `Un-Promote Widget: ${widget.label ?? widget.name}`,
callback: () => {
demoteWidget(node, widget, parents)
widget.callback?.(widget.value)
}
})
}

View File

@@ -187,7 +187,7 @@ export class ClipspaceDialog extends ComfyDialog {
app.registerExtension({
name: 'Comfy.Clipspace',
init(app) {
init() {
app.openClipspace = function () {
if (!ClipspaceDialog.instance) {
ClipspaceDialog.instance = new ClipspaceDialog()

View File

@@ -16,15 +16,16 @@ useExtensionService().registerExtension({
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
// Refresh config when subscription status changes
// Initial auth-aware refresh happens in WorkspaceAuthGate before app renders
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount
// This watcher handles subscription changes and acts as a backup for auth
watchDebounced(
[isLoggedIn, isActiveSubscription],
() => {
if (!isLoggedIn.value) return
void refreshRemoteConfig()
},
{ debounce: 256 }
{ debounce: 256, immediate: true }
)
// Poll for config updates every 10 minutes (with auth)

View File

@@ -55,6 +55,7 @@ class Load3d {
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
private resizeObserver: ResizeObserver | null = null
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
@@ -145,6 +146,7 @@ class Load3d {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
@@ -154,6 +156,16 @@ class Load3d {
}, 100)
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
@@ -512,7 +524,6 @@ class Load3d {
this.viewHelperManager.recreateViewHelper()
this.handleResize()
this.forceRender()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
@@ -574,7 +585,6 @@ class Load3d {
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
@@ -608,7 +618,6 @@ class Load3d {
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
@@ -621,7 +630,6 @@ class Load3d {
refreshViewport(): void {
this.handleResize()
this.forceRender()
}
handleResize(): void {
@@ -809,6 +817,11 @@ class Load3d {
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null

View File

@@ -13,7 +13,7 @@ import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
app.registerExtension({
name: 'Comfy.RerouteNode',
registerCustomNodes(app) {
registerCustomNodes() {
interface RerouteNode extends LGraphNode {
__outputType?: string | number
}

View File

@@ -21,7 +21,7 @@ const saveNodeTypes = new Set([
app.registerExtension({
name: 'Comfy.SaveImageExtraOutput',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
async beforeRegisterNodeDef(nodeType, nodeData) {
if (saveNodeTypes.has(nodeData.name)) {
const onNodeCreated = nodeType.prototype.onNodeCreated
// When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R

View File

@@ -511,7 +511,7 @@ export function mergeIfValid(
app.registerExtension({
name: 'Comfy.WidgetInputs',
async beforeRegisterNodeDef(nodeType, _nodeData, app) {
async beforeRegisterNodeDef(nodeType, _nodeData) {
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
console.warn(

View File

@@ -46,12 +46,9 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {

View File

@@ -1,3 +1,4 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import { LGraphIcon } from './LGraphIcon'
import type { LGraphIconOptions } from './LGraphIcon'
@@ -15,6 +16,7 @@ export interface LGraphBadgeOptions {
height?: number
cornerRadius?: number
iconOptions?: LGraphIconOptions
onClick?: (e: MouseEvent) => void
xOffset?: number
yOffset?: number
}
@@ -28,9 +30,15 @@ export class LGraphBadge {
height: number
cornerRadius: number
icon?: LGraphIcon
onClick?: (e: MouseEvent) => void
xOffset: number
yOffset: number
readonly _boundingRect: [number, number, number, number] = [0, 0, 0, 0]
get boundingRect(): ReadOnlyRect {
return this._boundingRect
}
constructor({
text,
fgColor = 'white',
@@ -40,6 +48,7 @@ export class LGraphBadge {
height = 20,
cornerRadius = 5,
iconOptions,
onClick,
xOffset = 0,
yOffset = 0
}: LGraphBadgeOptions) {
@@ -53,6 +62,7 @@ export class LGraphBadge {
if (iconOptions) {
this.icon = new LGraphIcon(iconOptions)
}
this.onClick = onClick
this.xOffset = xOffset
this.yOffset = yOffset
}
@@ -91,6 +101,8 @@ export class LGraphBadge {
const badgeWidth = this.getWidth(ctx)
const badgeX = 0
this._boundingRect.splice(0, 4, x, y, badgeWidth, this.height)
// Draw badge background
ctx.fillStyle = this.bgColor
ctx.beginPath()

View File

@@ -1,4 +1,5 @@
import { toString } from 'es-toolkit/compat'
import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -2801,6 +2802,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
}
for (const badge of node.badges.map(toValue).filter((b) => b.onClick)) {
if (isInRect(pos[0], pos[1], badge.boundingRect)) {
pointer.onClick = badge.onClick
return
}
}
// Mousedown callback - can block drag
if (node.onMouseDown?.(e, pos, this)) {

View File

@@ -18,6 +18,7 @@ import {
containsCentre,
containsRect,
createBounds,
isInRect,
isInRectangle,
isPointInRect,
snapPoint
@@ -370,6 +371,8 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
)
}
isPointInside = LGraphNode.prototype.isPointInside
isPointInside(x: number, y: number): boolean {
return isInRect(x, y, this.boundingRect)
}
setDirtyCanvas = LGraphNode.prototype.setDirtyCanvas
}

View File

@@ -1,3 +1,5 @@
import { toValue } from 'vue'
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPosFromSlot,
@@ -233,6 +235,14 @@ export class LGraphNode
static description?: string
static filter?: string
static skip_list?: boolean
static nodeData?: {
dev_only?: boolean
deprecated?: boolean
experimental?: boolean
output_node?: boolean
api_node?: boolean
name?: string
}
static resizeHandleSize = 15
static resizeEdgeSize = 5
@@ -2076,7 +2086,13 @@ export class LGraphNode
* checks if a point is inside the shape of a node
*/
isPointInside(x: number, y: number): boolean {
return isInRect(x, y, this.boundingRect)
if (isInRect(x, y, this.boundingRect)) return true
for (const badge of this.badges.map(toValue).filter((b) => b.onClick)) {
if (isInRect(x - this.pos[0], y - this.pos[1], badge.boundingRect))
return true
}
return false
}
/**

View File

@@ -26,7 +26,6 @@ LGraph {
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",

View File

@@ -1,12 +1,15 @@
import { clamp } from 'es-toolkit/compat'
import { beforeEach, describe, expect, vi } from 'vitest'
import { describe, expect } from 'vitest'
import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph
LiteGraph,
LGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
import { test } from './__fixtures__/testExtensions'
describe('Litegraph module', () => {
@@ -27,22 +30,9 @@ describe('Litegraph module', () => {
})
describe('Import order dependency', () => {
beforeEach(() => {
vi.resetModules()
})
test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')
// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}
await expect(importNormally()).resolves.toBeUndefined()
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
})
})

View File

@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,12 +1,26 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
/**
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
* Widget for displaying a color picker using native HTML color input
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -15,35 +29,59 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { y, height } = this
const { height, y } = this
const { margin } = BaseWidget
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
}
}

View File

@@ -107,6 +107,7 @@
"modelUploaded": "تم استيراد النموذج بنجاح.",
"noAssetsFound": "لم يتم العثور على أصول",
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
"noResultsCanImport": "حاول تعديل البحث أو عوامل التصفية.\nيمكنك أيضًا إضافة النماذج باستخدام زر \"استيراد\" أعلاه.",
"noValidSourceDetected": "لم يتم اكتشاف مصدر استيراد صالح",
"notSureLeaveAsIs": "لست متأكدًا؟ فقط اتركه كما هو",
"onlyCivitaiUrlsSupported": "يتم دعم روابط Civitai فقط",
@@ -748,6 +749,7 @@
"deleteImage": "حذف الصورة",
"deprecated": "مهمل",
"description": "الوصف",
"devOnly": "للمطورين فقط",
"devices": "الأجهزة",
"disableAll": "تعطيل الكل",
"disableSelected": "تعطيل المحدد",
@@ -1260,8 +1262,10 @@
}
},
"manager": {
"actions": "الإجراءات",
"allMissingNodesInstalled": "تم تثبيت جميع العقد المفقودة بنجاح",
"applyChanges": "تطبيق التغييرات",
"basicInfo": "معلومات أساسية",
"changingVersion": "تغيير الإصدار من {from} إلى {to}",
"clickToFinishSetup": "انقر",
"conflicts": {
@@ -1324,12 +1328,24 @@
"license": "الرخصة",
"loadingVersions": "جاري تحميل الإصدارات...",
"mixedSelectionMessage": "لا يمكن تنفيذ إجراء جماعي على تحديد مختلط",
"nav": {
"allExtensions": "جميع الإضافات",
"allInWorkflow": "الكل في: {workflowName}",
"allInstalled": "جميع المثبتة",
"conflicting": "تعارض",
"inWorkflowSection": "في سير العمل",
"installedSection": "المثبتة",
"missingNodes": "عقد مفقودة",
"notInstalled": "غير مثبت",
"updatesAvailable": "تحديثات متوفرة"
},
"nightlyVersion": "ليلي",
"noDescription": "لا يوجد وصف متاح",
"noNodesFound": "لم يتم العثور على عقد",
"noNodesFoundDescription": "لم يمكن تحليل عقد الحزمة، أو أن الحزمة هي امتداد للواجهة فقط ولا تحتوي على أي عقد.",
"noResultsFound": "لم يتم العثور على نتائج مطابقة لبحثك.",
"nodePack": "حزمة العقد",
"nodePackInfo": "معلومات حزمة العقد",
"notAvailable": "غير متوفر",
"packsSelected": "الحزم المحددة",
"repository": "المستودع",
@@ -1337,6 +1353,7 @@
"restartingBackend": "جاري إعادة تشغيل الخلفية لتطبيق التغييرات...",
"searchPlaceholder": "بحث",
"selectVersion": "اختر الإصدار",
"selected": "المحدد",
"sort": {
"created": "الأحدث",
"downloads": "الأكثر شيوعاً",
@@ -1669,6 +1686,7 @@
"Bria": "Bria",
"ByteDance": "بايت دانس",
"Gemini": "جيميني",
"Grok": "Grok",
"Ideogram": "إيديوغرام",
"Kling": "Kling",
"LTXV": "LTXV",
@@ -2248,6 +2266,7 @@
"filterBy": "تصفية حسب",
"filterCurrentWorkflow": "سير العمل الحالي",
"filterJobs": "تصفية المهام",
"inlineTotalLabel": "الإجمالي",
"interruptAll": "إيقاف جميع المهام الجارية",
"jobQueue": "قائمة المهام",
"jobsCompleted": "{count} مهمة مكتملة | {count} مهام مكتملة",
@@ -2294,6 +2313,7 @@
},
"subgraphStore": {
"blueprintName": "اسم المخطط الفرعي",
"cannotDeleteGlobal": "لا يمكن حذف المخططات المثبتة",
"confirmDelete": "سيؤدي هذا الإجراء إلى إزالة المخطط نهائيًا من مكتبتك",
"confirmDeleteTitle": "حذف المخطط؟",
"hidden": "معاملات مخفية / متداخلة",

View File

@@ -3384,6 +3384,142 @@
}
}
},
"GrokImageEditNode": {
"description": "تعديل صورة موجودة بناءً على مطالبة نصية",
"display_name": "تعديل صورة Grok",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"image": {
"name": "الصورة"
},
"model": {
"name": "النموذج"
},
"number_of_images": {
"name": "عدد الصور",
"tooltip": "عدد الصور المعدلة التي سيتم توليدها"
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة النصية المستخدمة لتوليد الصورة"
},
"resolution": {
"name": "الدقة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "توليد صور باستخدام Grok بناءً على مطالبة نصية",
"display_name": "صورة Grok",
"inputs": {
"aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع"
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"number_of_images": {
"name": "عدد الصور",
"tooltip": "عدد الصور التي سيتم توليدها"
},
"prompt": {
"name": "المطالبة",
"tooltip": "المطالبة النصية المستخدمة لتوليد الصورة"
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoEditNode": {
"description": "تعديل فيديو موجود بناءً على مطالبة نصية.",
"display_name": "تعديل فيديو Grok",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"model": {
"name": "النموذج"
},
"prompt": {
"name": "المطالبة",
"tooltip": "وصف نصي للفيديو المطلوب."
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
},
"video": {
"name": "الفيديو",
"tooltip": "المدة القصوى المدعومة هي ٨٫٧ ثوانٍ وحجم الملف ٥٠ ميجابايت."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "توليد فيديو من مطالبة أو صورة",
"display_name": "فيديو Grok",
"inputs": {
"aspect_ratio": {
"name": "نسبة العرض إلى الارتفاع",
"tooltip": "نسبة العرض إلى الارتفاع للفيديو الناتج."
},
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"duration": {
"name": "المدة",
"tooltip": "مدة الفيديو الناتج بالثواني."
},
"image": {
"name": "الصورة"
},
"model": {
"name": "النموذج"
},
"prompt": {
"name": "المطالبة",
"tooltip": "وصف نصي للفيديو المطلوب."
},
"resolution": {
"name": "الدقة",
"tooltip": "دقة الفيديو الناتج."
},
"seed": {
"name": "البذرة",
"tooltip": "البذرة لتحديد ما إذا كان يجب إعادة تشغيل العقدة؛ النتائج الفعلية غير حتمية بغض النظر عن البذرة."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "توسيع القناع",
"inputs": {

View File

@@ -121,6 +121,7 @@
"customize": "Customize",
"experimental": "BETA",
"deprecated": "DEPR",
"devOnly": "DEV",
"loadWorkflow": "Load Workflow",
"goToNode": "Go to Node",
"setAsBackground": "Set as Background",
@@ -756,6 +757,7 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -993,7 +995,8 @@
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
"showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",
@@ -1490,6 +1493,7 @@
"Gemini": "Gemini",
"video_models": "video_models",
"gligen": "gligen",
"Grok": "Grok",
"sd": "sd",
"Ideogram": "Ideogram",
"postprocessing": "postprocessing",
@@ -2544,7 +2548,7 @@
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",

View File

@@ -3391,6 +3391,142 @@
}
}
},
"GrokImageEditNode": {
"display_name": "Grok Image Edit",
"description": "Modify an existing image based on a text prompt",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"prompt": {
"name": "prompt",
"tooltip": "The text prompt used to generate the image"
},
"resolution": {
"name": "resolution"
},
"number_of_images": {
"name": "number_of_images",
"tooltip": "Number of edited images to generate"
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"display_name": "Grok Image",
"description": "Generate images using Grok based on a text prompt",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "The text prompt used to generate the image"
},
"aspect_ratio": {
"name": "aspect_ratio"
},
"number_of_images": {
"name": "number_of_images",
"tooltip": "Number of images to generate"
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoEditNode": {
"display_name": "Grok Video Edit",
"description": "Edit an existing video based on a text prompt.",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired video."
},
"video": {
"name": "video",
"tooltip": "Maximum supported duration is 8.7 seconds and 50MB file size."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"display_name": "Grok Video",
"description": "Generate video from a prompt or an image",
"inputs": {
"model": {
"name": "model"
},
"prompt": {
"name": "prompt",
"tooltip": "Text description of the desired video."
},
"resolution": {
"name": "resolution",
"tooltip": "The resolution of the output video."
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "The aspect ratio of the output video."
},
"duration": {
"name": "duration",
"tooltip": "The duration of the output video in seconds."
},
"seed": {
"name": "seed",
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
},
"image": {
"name": "image"
},
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "Grow Mask",
"inputs": {

View File

@@ -107,6 +107,7 @@
"modelUploaded": "Modelo importado correctamente.",
"noAssetsFound": "No se encontraron recursos",
"noModelsInFolder": "No hay {type} disponibles en esta carpeta",
"noResultsCanImport": "Intenta ajustar tu búsqueda o filtros.\nTambién puedes añadir modelos usando el botón \"Importar\" de arriba.",
"noValidSourceDetected": "No se detectó una fuente de importación válida",
"notSureLeaveAsIs": "¿No estás seguro? Déjalo como está",
"onlyCivitaiUrlsSupported": "Solo se admiten URLs de Civitai",
@@ -748,6 +749,7 @@
"deleteImage": "Eliminar imagen",
"deprecated": "DEPR",
"description": "Descripción",
"devOnly": "DEV",
"devices": "Dispositivos",
"disableAll": "Deshabilitar todo",
"disableSelected": "Deshabilitar seleccionados",
@@ -1260,8 +1262,10 @@
}
},
"manager": {
"actions": "Acciones",
"allMissingNodesInstalled": "Todos los nodos faltantes se han instalado exitosamente",
"applyChanges": "Aplicar Cambios",
"basicInfo": "Información básica",
"changingVersion": "Cambiando versión de {from} a {to}",
"clickToFinishSetup": "Haz clic",
"conflicts": {
@@ -1324,12 +1328,24 @@
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"mixedSelectionMessage": "No se puede realizar acción masiva en selección mixta",
"nav": {
"allExtensions": "Todas las extensiones",
"allInWorkflow": "Todo en: {workflowName}",
"allInstalled": "Todo instalado",
"conflicting": "En conflicto",
"inWorkflowSection": "EN EL FLUJO DE TRABAJO",
"installedSection": "INSTALADO",
"missingNodes": "Nodos faltantes",
"notInstalled": "No instalado",
"updatesAvailable": "Actualizaciones disponibles"
},
"nightlyVersion": "Nocturna",
"noDescription": "No hay descripción disponible",
"noNodesFound": "No se encontraron nodos",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
"nodePack": "Paquete de Nodos",
"nodePackInfo": "Información del paquete de nodos",
"notAvailable": "No Disponible",
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
@@ -1337,6 +1353,7 @@
"restartingBackend": "Reiniciando backend para aplicar cambios...",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar Versión",
"selected": "Seleccionado",
"sort": {
"created": "Más reciente",
"downloads": "Más Popular",
@@ -1669,6 +1686,7 @@
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Grok": "Grok",
"Ideogram": "Ideogram",
"Kling": "Kling",
"LTXV": "LTXV",
@@ -2248,6 +2266,7 @@
"filterBy": "Filtrar por",
"filterCurrentWorkflow": "Flujo de trabajo actual",
"filterJobs": "Filtrar trabajos",
"inlineTotalLabel": "Total",
"interruptAll": "Interrumpir todos los trabajos en ejecución",
"jobQueue": "Cola de trabajos",
"jobsCompleted": "{count} trabajo completado | {count} trabajos completados",
@@ -2294,6 +2313,7 @@
},
"subgraphStore": {
"blueprintName": "Nombre del subgrafo",
"cannotDeleteGlobal": "No se pueden eliminar los blueprints instalados",
"confirmDelete": "Esta acción eliminará permanentemente el subgrafo de tu biblioteca",
"confirmDeleteTitle": "¿Eliminar subgrafo?",
"hidden": "Parámetros ocultos/anidados",

View File

@@ -3384,6 +3384,142 @@
}
}
},
"GrokImageEditNode": {
"description": "Modifica una imagen existente según una indicación de texto",
"display_name": "Edición de imagen Grok",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"image": {
"name": "imagen"
},
"model": {
"name": "modelo"
},
"number_of_images": {
"name": "número de imágenes",
"tooltip": "Cantidad de imágenes editadas a generar"
},
"prompt": {
"name": "indicación",
"tooltip": "La indicación de texto utilizada para generar la imagen"
},
"resolution": {
"name": "resolución"
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "Genera imágenes usando Grok a partir de una indicación de texto",
"display_name": "Imagen Grok",
"inputs": {
"aspect_ratio": {
"name": "relación de aspecto"
},
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo"
},
"number_of_images": {
"name": "número de imágenes",
"tooltip": "Cantidad de imágenes a generar"
},
"prompt": {
"name": "indicación",
"tooltip": "La indicación de texto utilizada para generar la imagen"
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoEditNode": {
"description": "Edita un video existente según una indicación de texto.",
"display_name": "Edición de video Grok",
"inputs": {
"control_after_generate": {
"name": "controlar después de generar"
},
"model": {
"name": "modelo"
},
"prompt": {
"name": "indicación",
"tooltip": "Descripción en texto del video deseado."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
},
"video": {
"name": "video",
"tooltip": "La duración máxima admitida es de 8,7 segundos y el tamaño máximo de archivo es de 50MB."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "Genera video a partir de una indicación o una imagen",
"display_name": "Video Grok",
"inputs": {
"aspect_ratio": {
"name": "relación de aspecto",
"tooltip": "La relación de aspecto del video de salida."
},
"control_after_generate": {
"name": "controlar después de generar"
},
"duration": {
"name": "duración",
"tooltip": "La duración del video de salida en segundos."
},
"image": {
"name": "imagen"
},
"model": {
"name": "modelo"
},
"prompt": {
"name": "indicación",
"tooltip": "Descripción en texto del video deseado."
},
"resolution": {
"name": "resolución",
"tooltip": "La resolución del video de salida."
},
"seed": {
"name": "semilla",
"tooltip": "Semilla para determinar si el nodo debe ejecutarse de nuevo; los resultados reales son no deterministas independientemente de la semilla."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "GrowMask",
"inputs": {

View File

@@ -107,6 +107,7 @@
"modelUploaded": "مدل با موفقیت وارد شد.",
"noAssetsFound": "هیچ دارایی‌ای یافت نشد",
"noModelsInFolder": "هیچ {type} در این پوشه موجود نیست",
"noResultsCanImport": "جستجو یا فیلترهای خود را تغییر دهید.\nهمچنین می‌توانید مدل‌ها را با استفاده از دکمه «وارد کردن» در بالا اضافه کنید.",
"noValidSourceDetected": "هیچ منبع واردات معتبری شناسایی نشد",
"notSureLeaveAsIs": "مطمئن نیستید؟ همین را باقی بگذارید",
"onlyCivitaiUrlsSupported": "فقط URLهای Civitai پشتیبانی می‌شوند",
@@ -748,6 +749,7 @@
"deleteImage": "حذف تصویر",
"deprecated": "منسوخ",
"description": "توضیحات",
"devOnly": "فقط برای توسعه‌دهندگان",
"devices": "دستگاه‌ها",
"disableAll": "غیرفعال‌سازی همه",
"disableSelected": "غیرفعال‌سازی انتخاب‌شده‌ها",
@@ -1260,8 +1262,10 @@
}
},
"manager": {
"actions": "اقدامات",
"allMissingNodesInstalled": "همه نودهای مفقود با موفقیت نصب شدند",
"applyChanges": "اعمال تغییرات",
"basicInfo": "اطلاعات پایه",
"changingVersion": "تغییر نسخه از {from} به {to}",
"clickToFinishSetup": "کلیک کنید",
"conflicts": {
@@ -1324,12 +1328,24 @@
"license": "مجوز",
"loadingVersions": "در حال بارگذاری نسخه‌ها...",
"mixedSelectionMessage": "امکان انجام عملیات گروهی روی انتخاب ترکیبی وجود ندارد",
"nav": {
"allExtensions": "همه افزونه‌ها",
"allInWorkflow": "همه در: {workflowName}",
"allInstalled": "همه نصب شده‌ها",
"conflicting": "دارای تداخل",
"inWorkflowSection": "در Workflow",
"installedSection": "نصب شده",
"missingNodes": "Nodeهای مفقود",
"notInstalled": "نصب نشده",
"updatesAvailable": "به‌روزرسانی‌های موجود"
},
"nightlyVersion": "نسخه nightly",
"noDescription": "توضیحی موجود نیست",
"noNodesFound": "نودی یافت نشد",
"noNodesFoundDescription": "نودهای این بسته قابل تجزیه نبودند یا این بسته فقط یک افزونه فرانت‌اند است و نودی ندارد.",
"noResultsFound": "نتیجه‌ای مطابق با جستجوی شما یافت نشد.",
"nodePack": "بسته نود",
"nodePackInfo": "اطلاعات Node Pack",
"notAvailable": "در دسترس نیست",
"packsSelected": "بسته انتخاب شد",
"repository": "مخزن",
@@ -1337,6 +1353,7 @@
"restartingBackend": "در حال راه‌اندازی مجدد backend برای اعمال تغییرات...",
"searchPlaceholder": "جستجو",
"selectVersion": "انتخاب نسخه",
"selected": "انتخاب شده",
"sort": {
"created": "جدیدترین",
"downloads": "محبوب‌ترین",
@@ -1669,6 +1686,7 @@
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Grok": "Grok",
"Ideogram": "Ideogram",
"Kling": "Kling",
"LTXV": "LTXV",
@@ -2259,6 +2277,7 @@
"filterBy": "فیلتر بر اساس",
"filterCurrentWorkflow": "Workflow فعلی",
"filterJobs": "فیلتر کارها",
"inlineTotalLabel": "کل",
"interruptAll": "توقف همه کارهای در حال اجرا",
"jobQueue": "صف کار",
"jobsCompleted": "{count} کار تکمیل شد",
@@ -2305,6 +2324,7 @@
},
"subgraphStore": {
"blueprintName": "نام زیرگراف",
"cannotDeleteGlobal": "امکان حذف blueprints نصب‌شده وجود ندارد",
"confirmDelete": "این عمل باعث حذف دائمی بلوپرینت از کتابخانه شما می‌شود",
"confirmDeleteTitle": "حذف بلوپرینت؟",
"hidden": "پارامترهای مخفی / تو در تو",

View File

@@ -3389,6 +3389,142 @@
}
}
},
"GrokImageEditNode": {
"description": "ویرایش یک تصویر موجود بر اساس یک متن راهنما",
"display_name": "ویرایش تصویر Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"image": {
"name": "تصویر"
},
"model": {
"name": "مدل"
},
"number_of_images": {
"name": "تعداد تصاویر",
"tooltip": "تعداد تصاویر ویرایش‌شده برای تولید"
},
"prompt": {
"name": "راهنما",
"tooltip": "متن راهنما برای تولید تصویر استفاده می‌شود"
},
"resolution": {
"name": "وضوح"
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اینکه node باید دوباره اجرا شود؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "تولید تصویر با استفاده از Grok بر اساس یک متن راهنما",
"display_name": "تصویر Grok",
"inputs": {
"aspect_ratio": {
"name": "نسبت ابعاد"
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل"
},
"number_of_images": {
"name": "تعداد تصاویر",
"tooltip": "تعداد تصاویر برای تولید"
},
"prompt": {
"name": "راهنما",
"tooltip": "متن راهنما برای تولید تصویر استفاده می‌شود"
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اینکه node باید دوباره اجرا شود؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoEditNode": {
"description": "ویرایش یک ویدیوی موجود بر اساس یک متن راهنما.",
"display_name": "ویرایش ویدیو Grok",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"model": {
"name": "مدل"
},
"prompt": {
"name": "راهنما",
"tooltip": "توضیح متنی از ویدیوی مورد نظر."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اینکه node باید دوباره اجرا شود؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
},
"video": {
"name": "ویدیو",
"tooltip": "حداکثر مدت زمان پشتیبانی‌شده ۸.۷ ثانیه و حجم فایل ۵۰ مگابایت است."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "تولید ویدیو از یک راهنما یا تصویر",
"display_name": "ویدیو Grok",
"inputs": {
"aspect_ratio": {
"name": "نسبت ابعاد",
"tooltip": "نسبت ابعاد ویدیوی خروجی."
},
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"duration": {
"name": "مدت زمان",
"tooltip": "مدت زمان ویدیوی خروجی بر حسب ثانیه."
},
"image": {
"name": "تصویر"
},
"model": {
"name": "مدل"
},
"prompt": {
"name": "راهنما",
"tooltip": "توضیح متنی از ویدیوی مورد نظر."
},
"resolution": {
"name": "وضوح",
"tooltip": "وضوح ویدیوی خروجی."
},
"seed": {
"name": "بذر",
"tooltip": "بذر برای تعیین اینکه node باید دوباره اجرا شود؛ نتایج واقعی صرف‌نظر از بذر غیرقطعی هستند."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "گسترش ماسک",
"inputs": {

View File

@@ -107,6 +107,7 @@
"modelUploaded": "Modèle importé avec succès.",
"noAssetsFound": "Aucune ressource trouvée",
"noModelsInFolder": "Aucun {type} disponible dans ce dossier",
"noResultsCanImport": "Essayez dajuster votre recherche ou vos filtres.\nVous pouvez également ajouter des modèles en utilisant le bouton « Importer » ci-dessus.",
"noValidSourceDetected": "Aucune source d'importation valide détectée",
"notSureLeaveAsIs": "Vous n'êtes pas sûr ? Laissez tel quel",
"onlyCivitaiUrlsSupported": "Seules les URL Civitai sont prises en charge",
@@ -748,6 +749,7 @@
"deleteImage": "Supprimer l'image",
"deprecated": "DEPR",
"description": "Description",
"devOnly": "DEV",
"devices": "Appareils",
"disableAll": "Désactiver tout",
"disableSelected": "Désactiver la sélection",
@@ -1260,8 +1262,10 @@
}
},
"manager": {
"actions": "Actions",
"allMissingNodesInstalled": "Tous les nœuds manquants ont été installés avec succès",
"applyChanges": "Appliquer les modifications",
"basicInfo": "Informations de base",
"changingVersion": "Changement de version de {from} à {to}",
"clickToFinishSetup": "Cliquez",
"conflicts": {
@@ -1324,12 +1328,24 @@
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"mixedSelectionMessage": "Impossible d'effectuer une action groupée sur une sélection mixte",
"nav": {
"allExtensions": "Toutes les extensions",
"allInWorkflow": "Tout dans : {workflowName}",
"allInstalled": "Tout installé",
"conflicting": "En conflit",
"inWorkflowSection": "DANS LE FLUX DE TRAVAIL",
"installedSection": "INSTALLÉ",
"missingNodes": "Nœuds manquants",
"notInstalled": "Non installé",
"updatesAvailable": "Mises à jour disponibles"
},
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noNodesFound": "Aucun nœud trouvé",
"noNodesFoundDescription": "Les nœuds du pack n'ont pas pu être analysés, ou le pack est une extension frontend uniquement et n'a pas de nœuds.",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"nodePackInfo": "Informations sur le pack de nœuds",
"notAvailable": "Non disponible",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
@@ -1337,6 +1353,7 @@
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"selected": "Sélectionné",
"sort": {
"created": "Le plus récent",
"downloads": "Le plus populaire",
@@ -1669,6 +1686,7 @@
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Grok": "Grok",
"Ideogram": "Ideogram",
"Kling": "Kling",
"LTXV": "LTXV",
@@ -2248,6 +2266,7 @@
"filterBy": "Filtrer par",
"filterCurrentWorkflow": "Workflow actuel",
"filterJobs": "Filtrer les travaux",
"inlineTotalLabel": "Total",
"interruptAll": "Interrompre tous les travaux en cours",
"jobQueue": "File dattente des travaux",
"jobsCompleted": "{count} travail terminé | {count} travaux terminés",
@@ -2294,6 +2313,7 @@
},
"subgraphStore": {
"blueprintName": "Nom du sous-graphe",
"cannotDeleteGlobal": "Impossible de supprimer les blueprints installés",
"confirmDelete": "Cette action supprimera définitivement le plan de votre bibliothèque",
"confirmDeleteTitle": "Supprimer le plan ?",
"hidden": "Paramètres cachés / imbriqués",

View File

@@ -3384,6 +3384,142 @@
}
}
},
"GrokImageEditNode": {
"description": "Modifiez une image existante à partir d'une invite textuelle",
"display_name": "Grok Image Edit",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"image": {
"name": "image"
},
"model": {
"name": "modèle"
},
"number_of_images": {
"name": "nombre d'images",
"tooltip": "Nombre d'images modifiées à générer"
},
"prompt": {
"name": "invite",
"tooltip": "L'invite textuelle utilisée pour générer l'image"
},
"resolution": {
"name": "résolution"
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes, quelle que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokImageNode": {
"description": "Générez des images avec Grok à partir d'une invite textuelle",
"display_name": "Grok Image",
"inputs": {
"aspect_ratio": {
"name": "rapport d'aspect"
},
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle"
},
"number_of_images": {
"name": "nombre d'images",
"tooltip": "Nombre d'images à générer"
},
"prompt": {
"name": "invite",
"tooltip": "L'invite textuelle utilisée pour générer l'image"
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes, quelle que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoEditNode": {
"description": "Modifiez une vidéo existante à partir d'une invite textuelle.",
"display_name": "Grok Video Edit",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"model": {
"name": "modèle"
},
"prompt": {
"name": "invite",
"tooltip": "Description textuelle de la vidéo souhaitée."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes, quelle que soit la graine."
},
"video": {
"name": "vidéo",
"tooltip": "La durée maximale prise en charge est de 8,7 secondes et la taille du fichier de 50 Mo."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrokVideoNode": {
"description": "Générez une vidéo à partir d'une invite ou d'une image",
"display_name": "Grok Video",
"inputs": {
"aspect_ratio": {
"name": "rapport d'aspect",
"tooltip": "Le rapport d'aspect de la vidéo générée."
},
"control_after_generate": {
"name": "contrôle après génération"
},
"duration": {
"name": "durée",
"tooltip": "La durée de la vidéo générée en secondes."
},
"image": {
"name": "image"
},
"model": {
"name": "modèle"
},
"prompt": {
"name": "invite",
"tooltip": "Description textuelle de la vidéo souhaitée."
},
"resolution": {
"name": "résolution",
"tooltip": "La résolution de la vidéo générée."
},
"seed": {
"name": "graine",
"tooltip": "Graine pour déterminer si le nœud doit être relancé ; les résultats réels sont non déterministes, quelle que soit la graine."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"GrowMask": {
"display_name": "GrowMask",
"inputs": {

View File

@@ -107,6 +107,7 @@
"modelUploaded": "モデルが正常にインポートされました。",
"noAssetsFound": "アセットが見つかりません",
"noModelsInFolder": "このフォルダには{type}がありません",
"noResultsCanImport": "検索やフィルターを調整してみてください。\nまた、上の「インポート」ボタンからモデルを追加することもできます。",
"noValidSourceDetected": "有効なインポート元が検出されませんでした",
"notSureLeaveAsIs": "分からない場合はそのままにしてください",
"onlyCivitaiUrlsSupported": "CivitaiのURLのみサポートされています",
@@ -748,6 +749,7 @@
"deleteImage": "画像を削除",
"deprecated": "非推奨",
"description": "説明",
"devOnly": "DEV",
"devices": "デバイス",
"disableAll": "すべて無効にする",
"disableSelected": "選択したものを無効化",
@@ -1260,8 +1262,10 @@
}
},
"manager": {
"actions": "アクション",
"allMissingNodesInstalled": "すべての不足しているノードが正常にインストールされました",
"applyChanges": "変更を適用",
"basicInfo": "基本情報",
"changingVersion": "バージョンを {from} から {to} に変更",
"clickToFinishSetup": "クリック",
"conflicts": {
@@ -1324,12 +1328,24 @@
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"mixedSelectionMessage": "混在した選択では一括操作を実行できません",
"nav": {
"allExtensions": "すべての拡張機能",
"allInWorkflow": "{workflowName} 内のすべて",
"allInstalled": "すべてインストール済み",
"conflicting": "競合",
"inWorkflowSection": "ワークフロー内",
"installedSection": "インストール済み",
"missingNodes": "不足しているノード",
"notInstalled": "未インストール",
"updatesAvailable": "アップデートあり"
},
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noNodesFound": "ノードが見つかりません",
"noNodesFoundDescription": "パックのノードは解析できなかったか、パックがフロントエンドの拡張機能のみでノードがない可能性があります。",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"nodePackInfo": "ノードパック情報",
"notAvailable": "利用不可",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
@@ -1337,6 +1353,7 @@
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"selected": "選択済み",
"sort": {
"created": "最新",
"downloads": "最も人気",
@@ -1669,6 +1686,7 @@
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Grok": "Grok",
"Ideogram": "Ideogram",
"Kling": "Kling",
"LTXV": "LTXV",
@@ -2248,6 +2266,7 @@
"filterBy": "フィルター条件",
"filterCurrentWorkflow": "現在のワークフロー",
"filterJobs": "ジョブをフィルター",
"inlineTotalLabel": "合計",
"interruptAll": "すべての実行中ジョブを中断",
"jobQueue": "ジョブキュー",
"jobsCompleted": "{count}件のジョブが完了",
@@ -2294,6 +2313,7 @@
},
"subgraphStore": {
"blueprintName": "サブグラフ名",
"cannotDeleteGlobal": "インストール済みのブループリントは削除できません",
"confirmDelete": "この操作により、ライブラリからサブグラフが完全に削除されます",
"confirmDeleteTitle": "サブグラフを削除しますか?",
"hidden": "非表示/ネストされたパラメータ",

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