Compare commits

...

23 Commits

Author SHA1 Message Date
GitHub Action
ee4599368c [automated] Apply ESLint and Prettier fixes 2025-11-21 04:07:08 +00:00
Terry Jia
c781421cad live preview - String length and concatenate node 2025-11-20 22:46:44 -05:00
AustinMroz
bdf6d4dea2 Allow updating position and mode on missing nodes (#6792)
When a node is missing, attempts to serialize it will return the "last
known good" serialization to ensure that the node will still be
functional in the future (the node pack is installed/comfyui is
updated). However, this means even small and safe changes (like moving
the node out of the way or bypassing it so the workflow can be run) will
be discarded on reload.

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

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

Thanks to @Kosinkadink for bringing this up

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

**Base branch:** `main`

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

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


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

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

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

---------

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

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

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

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

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

## Screenshots (if applicable)

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

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

**Base branch:** `main`

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

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

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

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

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

## Root Cause

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

## Changes

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

## Test Plan

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

## Before

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

## After

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

---------

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

**Base branch:** `main`

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

---------

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

## Testing
- pnpm typecheck
- pnpm lint:fix

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

---------

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

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

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

## Screenshots (if applicable)

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

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

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

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

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

---------

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

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

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

**Base branch:** `main`

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

---------

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

See also: #6642

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

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

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

---------

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

## Testing
- pnpm typecheck
- pnpm lint:fix

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

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

**Base branch:** `main`

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

---------

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

View File

@@ -91,7 +91,7 @@
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",

View File

@@ -115,19 +115,18 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import type { ModelRef } from 'vue'
import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import type { UVMirror } from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
@@ -229,6 +228,10 @@ const validatePath = async (path: string | undefined) => {
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.isInsideAppInstallDir)
errors.push(t('install.insideAppInstallDir'))
if (validation.isInsideUpdaterCache)
errors.push(t('install.insideUpdaterCache'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)

View File

@@ -16,7 +16,8 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,

View File

@@ -85,6 +85,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI()
// Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
@@ -97,6 +98,13 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing)
)
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -123,6 +131,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -155,7 +164,11 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
}
return {
@@ -163,6 +176,8 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute,
getRunner,
processUpdate,

View File

@@ -0,0 +1,159 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,6 +47,28 @@
</div>
</div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
@@ -89,10 +111,10 @@
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -139,6 +161,27 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
const isValid = await electron.Validation.complete()

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -6,6 +6,7 @@ import {
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.vueNodes.waitForNodes()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.6",
"version": "1.33.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -128,7 +128,7 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",

55
pnpm-lock.yaml generated
View File

@@ -9,6 +9,9 @@ catalogs:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
@@ -318,8 +321,8 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
specifier: 'catalog:'
version: 0.5.5
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
@@ -709,8 +712,8 @@ importers:
apps/desktop-ui:
dependencies:
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
specifier: 'catalog:'
version: 0.5.5
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:../../packages/shared-frontend-utils
@@ -1453,8 +1456,8 @@ packages:
'@cacheable/utils@2.0.3':
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
'@comfyorg/comfyui-electron-types@0.4.73-0':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
'@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -4413,6 +4416,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -7000,6 +7006,11 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
@@ -7095,6 +7106,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7815,8 +7831,8 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.3:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -8992,7 +9008,7 @@ snapshots:
'@cacheable/utils@2.0.3': {}
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
'@comfyorg/comfyui-electron-types@0.5.5': {}
'@csstools/color-helpers@5.1.0': {}
@@ -10617,7 +10633,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.3
vue-component-type-helpers: 3.1.4
'@swc/helpers@0.5.17':
dependencies:
@@ -10989,7 +11005,7 @@ snapshots:
'@types/react@19.1.9':
dependencies:
csstype: 3.1.3
csstype: 3.2.3
'@types/semver@7.7.0': {}
@@ -12168,6 +12184,8 @@ snapshots:
csstype@3.1.3: {}
csstype@3.2.3: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -12594,7 +12612,7 @@ snapshots:
dependencies:
debug: 3.2.7
is-core-module: 2.16.1
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
optional: true
@@ -13740,7 +13758,7 @@ snapshots:
acorn: 8.15.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.7.2
semver: 7.7.3
jsonc-parser@3.2.0: {}
@@ -15345,6 +15363,13 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
optional: true
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.2
@@ -15449,6 +15474,8 @@ snapshots:
semver@7.7.2: {}
semver@7.7.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -16343,7 +16370,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.3: {}
vue-component-type-helpers@3.1.4: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -4,6 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->

View File

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

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview">
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
@@ -200,7 +200,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,15 +19,18 @@ function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
const layoutMutations = useLayoutMutations()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph

View File

@@ -1545,7 +1545,26 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
},
GeminiImageNode: {
displayPrice: '$0.03 per 1K tokens'
displayPrice: '~$0.039/Image (1K)'
},
GeminiImage2Node: {
displayPrice: (node: LGraphNode): string => {
const resolutionWidget = node.widgets?.find(
(w) => w.name === 'resolution'
) as IComboWidget
if (!resolutionWidget) return 'Token-based'
const resolution = String(resolutionWidget.value)
if (resolution.includes('1K')) {
return '~$0.134/Image'
} else if (resolution.includes('2K')) {
return '~$0.134/Image'
} else if (resolution.includes('4K')) {
return '~$0.24/Image'
}
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
@@ -1829,6 +1848,7 @@ export const useNodePricing = () => {
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
GeminiImage2Node: ['resolution'],
// OpenAI nodes
OpenAIChatNode: ['model'],
// ByteDance

View File

@@ -0,0 +1,42 @@
import type { ComputedRef } from 'vue'
import { ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
setting: TSettingKey
vue: Settings[TSettingKey]
litegraph: Settings[TSettingKey]
}
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
config: RenderModeSettingConfig<TSettingKey>,
isVueMode: ComputedRef<boolean>
) {
const settingStore = useSettingStore()
const vueValue = ref(config.vue)
const litegraphValue = ref(config.litegraph)
const lastWasVue = ref<boolean | null>(null)
const load = async (vue: boolean) => {
if (lastWasVue.value === vue) return
if (lastWasVue.value !== null) {
const currentValue = settingStore.get(config.setting)
if (lastWasVue.value) {
vueValue.value = currentValue
} else {
litegraphValue.value = currentValue
}
}
await settingStore.set(
config.setting,
vue ? vueValue.value : litegraphValue.value
)
lastWasVue.value = vue
}
watch(isVueMode, load, { immediate: true })
}

View File

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

View File

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

View File

@@ -0,0 +1,344 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
interface PropagationOptions {
/**
* Find output by name instead of index
*/
outputName?: string
/**
* Explicitly specify output index (default: 0)
*/
outputIndex?: number
/**
* Whether to call node.setOutputData (default: false)
*/
setOutputData?: boolean
/**
* Whether to update target widget values (default: true)
*/
updateWidget?: boolean
/**
* Whether to call widget.callback after updating (default: false)
*/
callWidgetCallback?: boolean
/**
* Whether to call targetNode.onExecuted (default: false)
*/
callOnExecuted?: boolean
/**
* Custom function to build the message for onExecuted
*/
messageBuilder?: (
targetNode: LGraphNode,
value: TWidgetValue,
link: any
) => any
/**
* Custom handlers for specific node types
* Return true if handled, false to continue with default behavior
*/
customHandlers?: Map<
string,
(node: LGraphNode, value: TWidgetValue, link: any) => boolean
>
/**
* Enable reentry protection (default: true)
*/
preventReentry?: boolean
}
/**
* Calculator function type for live preview nodes
* Takes input values and returns the computed output value
*/
type LivePreviewCalculator = (inputValues: any[]) => TWidgetValue
/**
* Configuration for setting up a live preview node
*/
interface LivePreviewNodeConfig {
/**
* The calculator function that computes output from inputs
*/
calculator: LivePreviewCalculator
/**
* Optional output index (default: 0)
*/
outputIndex?: number
/**
* Optional propagation options to use when propagating the result
*/
propagationOptions?: Omit<PropagationOptions, 'outputIndex' | 'setOutputData'>
}
/**
* Composable for managing live preview functionality in ComfyUI nodes
*
* @example
* ```typescript
* // In a node extension:
* const { setupLivePreviewNode, propagateLivePreview } = useLivePreview()
*
* // For computation nodes:
* setupLivePreviewNode(node, {
* calculator: (inputs) => {
* const [a, b] = inputs
* return a + b
* }
* })
*
* // For simple propagation:
* propagateLivePreview(node, value, {
* updateWidget: true,
* callOnExecuted: true
* })
* ```
*/
const propagationFlags = new WeakMap<LGraphNode, Set<string>>()
const nodeCalculators = new WeakMap<LGraphNode, LivePreviewNodeConfig>()
export function useLivePreview() {
function getPropagationKey(outputIndex: number): string {
return `propagating_${outputIndex}`
}
function isNodePropagating(node: LGraphNode, outputIndex: number): boolean {
const flags = propagationFlags.get(node)
return flags?.has(getPropagationKey(outputIndex)) ?? false
}
function setNodePropagating(
node: LGraphNode,
outputIndex: number,
value: boolean
): void {
if (!propagationFlags.has(node)) {
propagationFlags.set(node, new Set())
}
const flags = propagationFlags.get(node)!
const key = getPropagationKey(outputIndex)
if (value) {
flags.add(key)
} else {
flags.delete(key)
}
}
function collectNodeInputValues(node: LGraphNode): any[] {
const inputValues: any[] = []
const graph = node.graph as LGraph
if (!graph || !node.inputs) {
return inputValues
}
for (const input of node.inputs) {
if (input.link != null) {
const link = graph.links[input.link]
if (link) {
const sourceNode = graph.getNodeById(link.origin_id)
if (sourceNode && sourceNode.getOutputData) {
const outputData = sourceNode.getOutputData(link.origin_slot)
inputValues.push(outputData)
} else {
inputValues.push(undefined)
}
} else {
inputValues.push(undefined)
}
} else if (input.widget) {
const widget = node.widgets?.find((w) => w.name === input.widget?.name)
inputValues.push(widget?.value)
} else {
inputValues.push(undefined)
}
}
return inputValues
}
function triggerNodeRecalculation(node: LGraphNode): void {
const config = nodeCalculators.get(node)
if (!config) {
return
}
const inputValues = collectNodeInputValues(node)
const hasValidInputs = inputValues.some((v) => v !== undefined)
if (!hasValidInputs) {
return
}
try {
const result = config.calculator(inputValues)
if (result !== undefined) {
propagateLivePreview(node, result, {
outputIndex: config.outputIndex ?? 0,
setOutputData: true,
...config.propagationOptions
})
}
} catch (error) {
console.error(
`Error calculating live preview for node ${node.type}:`,
error
)
}
}
function propagateLivePreview(
sourceNode: LGraphNode,
value: TWidgetValue,
options: PropagationOptions = {}
): void {
const {
outputName,
outputIndex: explicitOutputIndex,
setOutputData = false,
updateWidget = true,
callWidgetCallback = false,
callOnExecuted = false,
messageBuilder,
customHandlers,
preventReentry = true
} = options
let outputIndex = explicitOutputIndex ?? 0
if (outputName && sourceNode.outputs) {
const foundIndex = sourceNode.outputs.findIndex(
(output) => output.name === outputName
)
if (foundIndex >= 0) {
outputIndex = foundIndex
}
}
if (preventReentry && isNodePropagating(sourceNode, outputIndex)) {
return
}
if (preventReentry) {
setNodePropagating(sourceNode, outputIndex, true)
}
try {
if (setOutputData && sourceNode.setOutputData && value !== undefined) {
sourceNode.setOutputData(outputIndex, value as any)
}
const output = sourceNode.outputs?.[outputIndex]
if (!output || !output.links || output.links.length === 0) {
return
}
const graph = sourceNode.graph as LGraph
if (!graph) {
return
}
for (const linkId of output.links) {
const link = graph.links[linkId]
if (!link) {
continue
}
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) {
continue
}
if (customHandlers?.has(targetNode.type)) {
const handler = customHandlers.get(targetNode.type)!
const handled = handler(targetNode, value, link)
if (handled) {
continue
}
}
if (updateWidget) {
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const targetWidget = targetNode.widgets?.find(
(w: IBaseWidget) => w.name === targetInput.widget?.name
)
if (targetWidget) {
targetWidget.value = value
if (callWidgetCallback && targetWidget.callback) {
targetWidget.callback(value)
}
}
}
}
const hasCalculator = nodeCalculators.has(targetNode)
if (hasCalculator) {
triggerNodeRecalculation(targetNode)
continue
}
if (callOnExecuted && targetNode.onExecuted) {
const message = messageBuilder
? messageBuilder(targetNode, value, link)
: { text: [value] }
targetNode.onExecuted(message)
}
}
} finally {
if (preventReentry) {
setNodePropagating(sourceNode, outputIndex, false)
}
}
}
function setupLivePreviewNode(
node: LGraphNode,
config: LivePreviewNodeConfig
): void {
nodeCalculators.set(node, config)
const originalOnExecuted = node.onExecuted
node.onExecuted = function (message: any) {
if (originalOnExecuted) {
originalOnExecuted.call(this, message)
}
if (message.text && Array.isArray(message.text)) {
const result = config.calculator(message.text)
if (result !== undefined) {
propagateLivePreview(this, result, {
outputIndex: config.outputIndex ?? 0,
setOutputData: true,
...config.propagationOptions
})
}
}
}
}
return {
propagateLivePreview,
setupLivePreviewNode
}
}

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import './matchType'
import './nodeTemplates'
import './noteNode'
import './previewAny'
import './stringOperations'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'

View File

@@ -0,0 +1,58 @@
import { useExtensionService } from '@/services/extensionService'
import { useLivePreview } from '@/composables/useLivePreview'
const { setupLivePreviewNode } = useLivePreview()
useExtensionService().registerExtension({
name: 'Comfy.StringLength',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === 'StringLength') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.call(this)
}
// Set up live preview with calculator
setupLivePreviewNode(this, {
calculator: (inputs) => {
const inputString = inputs[0]
if (inputString == null) return undefined
return String(inputString).length
},
propagationOptions: {
updateWidget: true,
callOnExecuted: true
}
})
}
}
}
})
useExtensionService().registerExtension({
name: 'Comfy.StringConcatenate',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === 'StringConcatenate') {
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.call(this)
}
// Set up live preview with calculator
setupLivePreviewNode(this, {
calculator: (inputs) => {
const [string_a, string_b, delimiter] = inputs
if (string_a == null && string_b == null) return undefined
return [string_a ?? '', string_b ?? ''].join(delimiter || '')
},
propagationOptions: {
updateWidget: true,
callOnExecuted: true
}
})
}
}
}
})

View File

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

View File

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

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
"Previous Opened Workflow": "سير العمل السابق المفتوح",
"Publish": "نشر",
"Queue Panel": "لوحة الانتظار",
"Queue Prompt": "قائمة انتظار التعليمات",
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
"Queue Selected Output Nodes": "قائمة انتظار عقد المخرجات المحددة",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "فتح سير العمل من نظام الملفات المحلي",
"queue": "قائمة الانتظار",
"queueTab": {
"backToAllTasks": "العودة إلى جميع المهام",
"clearPendingTasks": "مسح المهام المعلقة",
"containImagePreview": "ملء معاينة الصورة",
"coverImagePreview": "تكييف معاينة الصورة",
"filter": "تصفية النتائج",
"filters": {
"hideCached": "إخفاء المخزنة مؤقتًا",
"hideCanceled": "إخفاء الملغاة"
},
"showFlatList": "عرض القائمة المسطحة"
},
"templates": "القوالب",
"themeToggle": "تبديل المظهر",
"workflowTab": {

View File

@@ -221,6 +221,9 @@
"Comfy_ToggleHelpCenter": {
"label": "Help Center"
},
"Comfy_ToggleLinear": {
"label": "toggle linear mode"
},
"Comfy_ToggleTheme": {
"label": "Toggle Theme (Dark/Light)"
},
@@ -281,10 +284,6 @@
"label": "Toggle Node Library Sidebar",
"tooltip": "Node Library"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Toggle Queue Sidebar",
"tooltip": "Queue"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Toggle Workflows Sidebar",
"tooltip": "Workflows"

View File

@@ -402,6 +402,7 @@
"Copy Image": "Copy Image",
"Save Image": "Save Image",
"Rename": "Rename",
"RenameWidget": "Rename Widget",
"Copy": "Copy",
"Duplicate": "Duplicate",
"Paste": "Paste",
@@ -504,6 +505,8 @@
"cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
"insideAppInstallDir": "This folder is inside the ComfyUI Desktop application bundle and will be deleted during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"insideUpdaterCache": "This folder is inside the ComfyUI updater cache, which is cleared on every update. Select a different location for your data.",
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
"parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error",
@@ -679,18 +682,6 @@
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
"queueTab": {
"showFlatList": "Show Flat List",
"backToAllTasks": "Back to All Tasks",
"containImagePreview": "Fill Image Preview",
"coverImagePreview": "Fit Image Preview",
"clearPendingTasks": "Clear Pending Tasks",
"filter": "Filter Outputs",
"filters": {
"hideCached": "Hide Cached",
"hideCanceled": "Hide Canceled"
}
},
"queueProgressOverlay": {
"title": "Queue Progress",
"total": "Total: {percent}",
@@ -1111,6 +1102,7 @@
"Experimental: Enable AssetAPI": "Experimental: Enable AssetAPI",
"Canvas Performance": "Canvas Performance",
"Help Center": "Help Center",
"toggle linear mode": "toggle linear mode",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
@@ -1130,7 +1122,6 @@
"Assets": "Assets",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
"Workflows": "Workflows"
},
"desktopMenu": {
@@ -1412,6 +1403,7 @@
"stable_cascade": "stable_cascade",
"3d_models": "3d_models",
"style_model": "style_model",
"Topaz": "Topaz",
"Tripo": "Tripo",
"Veo": "Veo",
"Vidu": "Vidu",
@@ -1497,6 +1489,14 @@
"taskFailed": "Task failed to run.",
"cannotContinue": "Unable to continue - errors remain",
"defaultDescription": "An error occurred while running a maintenance task."
},
"unsafeMigration": {
"title": "Unsafe install location detected",
"generic": "Your current ComfyUI base path is in a location that may be deleted or modified during updates. To avoid data loss, move it to a safe folder.",
"appInstallDir": "Your base path is inside the ComfyUI Desktop application bundle. This folder may be deleted or overwritten during updates. Choose a directory outside the install folder, such as Documents/ComfyUI.",
"updaterCache": "Your base path is inside the ComfyUI updater cache, which is cleared on each update. Choose a different location for your data.",
"oneDrive": "Your base path is on OneDrive, which can cause sync issues and accidental data loss. Choose a local folder that is not managed by OneDrive.",
"action": "Use the \"Base path\" maintenance task below to move ComfyUI to a safe location."
}
},
"missingModelsDialog": {
@@ -2076,6 +2076,35 @@
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Upload model",
"uploadModelFromCivitai": "Upload a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
"uploadModelDescription3": "Max file size: 1 GB",
"civitaiLinkLabel": "Civitai model download link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Upload",
"uploadingModel": "Uploading model...",
"uploadSuccess": "Model uploaded successfully!",
"uploadFailed": "Upload failed",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"modelTypeSelectorLabel": "What type of model is this?",
"modelTypeSelectorPlaceholder": "Select model type",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model uploaded! 🎉",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
@@ -2087,6 +2116,13 @@
"sortZA": "Z-A",
"sortRecent": "Recent",
"sortPopular": "Popular",
"errorFileTooLarge": "File exceeds the maximum allowed size limit",
"errorFormatNotAllowed": "Only SafeTensor format is allowed",
"errorUnsafePickleScan": "CivitAI detected potentially unsafe code in this file",
"errorUnsafeVirusScan": "CivitAI detected malware or suspicious content in this file",
"errorModelTypeNotSupported": "This model type is not supported",
"errorUnknown": "An unexpected error occurred",
"errorUploadFailed": "Failed to upload asset. Please try again.",
"ariaLabel": {
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"

View File

@@ -2061,6 +2061,11 @@
"name": "batch_size",
"tooltip": "The number of latent images in the batch."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyLatentImage": {
@@ -2794,10 +2799,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -2819,10 +2826,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -8841,7 +8850,7 @@
}
},
"PreviewAny": {
"display_name": "Preview Any",
"display_name": "Preview as Text",
"inputs": {
"source": {
"name": "source"
@@ -11548,6 +11557,118 @@
}
}
},
"TopazImageEnhance": {
"display_name": "Topaz Image Enhance",
"description": "Industry-standard upscaling and image enhancement.",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"prompt": {
"name": "prompt",
"tooltip": "Optional text prompt for creative upscaling guidance."
},
"subject_detection": {
"name": "subject_detection"
},
"face_enhancement": {
"name": "face_enhancement",
"tooltip": "Enhance faces (if present) during processing."
},
"face_enhancement_creativity": {
"name": "face_enhancement_creativity",
"tooltip": "Set the creativity level for face enhancement."
},
"face_enhancement_strength": {
"name": "face_enhancement_strength",
"tooltip": "Controls how sharp enhanced faces are relative to the background."
},
"crop_to_fill": {
"name": "crop_to_fill",
"tooltip": "By default, the image is letterboxed when the output aspect ratio differs. Enable to crop the image to fill the output dimensions."
},
"output_width": {
"name": "output_width",
"tooltip": "Zero value means to calculate automatically (usually it will be original size or output_height if specified)."
},
"output_height": {
"name": "output_height",
"tooltip": "Zero value means to output in the same height as original or output width."
},
"creativity": {
"name": "creativity"
},
"face_preservation": {
"name": "face_preservation",
"tooltip": "Preserve subjects' facial identity."
},
"color_preservation": {
"name": "color_preservation",
"tooltip": "Preserve the original colors."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TopazVideoEnhance": {
"display_name": "Topaz Video Enhance",
"description": "Breathe new life into video with powerful upscaling and recovery technology.",
"inputs": {
"video": {
"name": "video"
},
"upscaler_enabled": {
"name": "upscaler_enabled"
},
"upscaler_model": {
"name": "upscaler_model"
},
"upscaler_resolution": {
"name": "upscaler_resolution"
},
"upscaler_creativity": {
"name": "upscaler_creativity",
"tooltip": "Creativity level (applies only to Starlight (Astra) Creative)."
},
"interpolation_enabled": {
"name": "interpolation_enabled"
},
"interpolation_model": {
"name": "interpolation_model"
},
"interpolation_slowmo": {
"name": "interpolation_slowmo",
"tooltip": "Slow-motion factor applied to the input video. For example, 2 makes the output twice as slow and doubles the duration."
},
"interpolation_frame_rate": {
"name": "interpolation_frame_rate",
"tooltip": "Output frame rate."
},
"interpolation_duplicate": {
"name": "interpolation_duplicate",
"tooltip": "Analyze the input for duplicate frames and remove them."
},
"interpolation_duplicate_threshold": {
"name": "interpolation_duplicate_threshold",
"tooltip": "Detection sensitivity for duplicate frames."
},
"dynamic_compression_level": {
"name": "dynamic_compression_level",
"tooltip": "CQP level."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TorchCompileModel": {
"display_name": "TorchCompileModel",
"inputs": {
@@ -12162,6 +12283,11 @@
"octree_resolution": {
"name": "octree_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VAEDecodeTiled": {
@@ -12586,6 +12712,11 @@
"threshold": {
"name": "threshold"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VoxelToMeshBasic": {
@@ -12597,6 +12728,11 @@
"threshold": {
"name": "threshold"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"VPScheduler": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Buscar actualizaciones"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Abrir carpeta de nodos personalizados"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Abrir carpeta de entradas"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Abrir carpeta de registros"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Abrir extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Abrir carpeta de modelos"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Abrir carpeta de salidas"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Abrir herramientas de desarrollo"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Guía de usuario de escritorio"
},
"Comfy-Desktop_Quit": {
"label": "Salir"
},
"Comfy-Desktop_Reinstall": {
"label": "Reinstalar"
},
"Comfy-Desktop_Restart": {
"label": "Reiniciar"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Abrir visor 3D (Beta) para el nodo seleccionado"
},
"Comfy_BrowseModelAssets": {
"label": "Experimental: Explorar recursos de modelos"
},
"Comfy_BrowseTemplates": {
"label": "Explorar plantillas"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Editar widgets de subgráficos"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Salir de subgrafo"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Agrupar nodos seleccionados"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Alternar promoción del widget sobre el que se pasa el cursor"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Desempaquetar el subgrafo seleccionado"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Mostrar Diálogo de Configuraciones"
},
"Comfy_ToggleAssetAPI": {
"label": "Experimental: Habilitar AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Rendimiento del lienzo"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Cerrar sesión"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Habilitar nodos Vue"
},
"Workspace_CloseWorkflow": {
"label": "Cerrar Flujo de Trabajo Actual"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de recursos",
"tooltip": "Recursos"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Alternar Barra Lateral de Biblioteca de Modelos",
"tooltip": "Biblioteca de Modelos"
@@ -298,31 +281,8 @@
"label": "Alternar Barra Lateral de Biblioteca de Nodos",
"tooltip": "Biblioteca de Nodos"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Alternar Barra Lateral de Cola",
"tooltip": "Cola"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Alternar Barra Lateral de Flujos de Trabajo",
"tooltip": "Flujos de Trabajo"
},
"Comfy_BrowseModelAssets": {
"label": "Experimental: Explorar recursos de modelos"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Editar widgets de subgráficos"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Alternar promoción del widget sobre el que se pasa el cursor"
},
"Comfy_ToggleAssetAPI": {
"label": "Experimental: Habilitar AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Experimental: Habilitar nodos Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Alternar barra lateral de recursos",
"tooltip": "Recursos"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Anclar/Desanclar nodos seleccionados",
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
"Publish": "Publicar",
"Queue Panel": "Panel de Cola",
"Queue Prompt": "Indicador de cola",
"Queue Prompt (Front)": "Indicador de cola (Frente)",
"Queue Selected Output Nodes": "Encolar nodos de salida seleccionados",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",
"queueTab": {
"backToAllTasks": "Volver a todas las tareas",
"clearPendingTasks": "Borrar tareas pendientes",
"containImagePreview": "Llenar vista previa de la imagen",
"coverImagePreview": "Ajustar vista previa de la imagen",
"filter": "Filtrar salidas",
"filters": {
"hideCached": "Ocultar en caché",
"hideCanceled": "Ocultar cancelados"
},
"showFlatList": "Mostrar lista plana"
},
"templates": "Plantillas",
"themeToggle": "Cambiar tema",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Vérifier les mises à jour"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Ouvrir le dossier des nœuds personnalisés"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Ouvrir le dossier des entrées"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Ouvrir le dossier des journaux"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Ouvrir extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Ouvrir le dossier des modèles"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Ouvrir le dossier des sorties"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Ouvrir les outils de développement"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Guide de l'utilisateur du bureau"
},
"Comfy-Desktop_Quit": {
"label": "Quitter"
},
"Comfy-Desktop_Reinstall": {
"label": "Réinstaller"
},
"Comfy-Desktop_Restart": {
"label": "Redémarrer"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Ouvrir le visualiseur 3D (bêta) pour le nœud sélectionné"
},
"Comfy_BrowseModelAssets": {
"label": "Expérimental : Parcourir les ressources de modèles"
},
"Comfy_BrowseTemplates": {
"label": "Parcourir les modèles"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Modifier les widgets de sous-graphe"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Quitter le sous-graphe"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Grouper les nœuds sélectionnés"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Activer/désactiver la promotion du widget survolé"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Décompresser le sous-graphe sélectionné"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Afficher la boîte de dialogue des paramètres"
},
"Comfy_ToggleAssetAPI": {
"label": "Expérimental : Activer AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Performance du canvas"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Se déconnecter"
},
"Experimental_ToggleVueNodes": {
"label": "Expérimental : Activer les nœuds Vue"
},
"Workspace_CloseWorkflow": {
"label": "Fermer le flux de travail actuel"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Basculer le mode focus"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Afficher/Masquer la barre latérale des ressources",
"tooltip": "Ressources"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Basculer la barre latérale de la bibliothèque de modèles",
"tooltip": "Bibliothèque de modèles"
@@ -298,31 +281,8 @@
"label": "Basculer la barre latérale de la bibliothèque de nœuds",
"tooltip": "Bibliothèque de nœuds"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Basculer la barre latérale de la file d'attente",
"tooltip": "File d'attente"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Basculer la barre latérale des flux de travail",
"tooltip": "Flux de travail"
},
"Comfy_BrowseModelAssets": {
"label": "Expérimental : Parcourir les ressources de modèles"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Modifier les widgets de sous-graphe"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Activer/désactiver la promotion du widget survolé"
},
"Comfy_ToggleAssetAPI": {
"label": "Expérimental : Activer AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Expérimental : Activer les nœuds Vue"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Afficher/Masquer la barre latérale des ressources",
"tooltip": "Ressources"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Épingler/Désépingler les nœuds sélectionnés",
"Previous Opened Workflow": "Flux de travail ouvert précédent",
"Publish": "Publier",
"Queue Panel": "Panneau de file d'attente",
"Queue Prompt": "Invite de file d'attente",
"Queue Prompt (Front)": "Invite de file d'attente (Front)",
"Queue Selected Output Nodes": "Mettre en file dattente les nœuds de sortie sélectionnés",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",
"queueTab": {
"backToAllTasks": "Retour à toutes les tâches",
"clearPendingTasks": "Effacer les tâches en attente",
"containImagePreview": "Remplir l'aperçu de l'image",
"coverImagePreview": "Adapter l'aperçu de l'image",
"filter": "Filtrer les sorties",
"filters": {
"hideCached": "Masquer le cache",
"hideCanceled": "Masquer les annulations"
},
"showFlatList": "Afficher la liste plate"
},
"templates": "Modèles",
"themeToggle": "Basculer le thème",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "更新を確認する"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "カスタムノードフォルダを開く"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "入力フォルダを開く"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "ログフォルダを開く"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yamlを開く"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "モデルフォルダを開く"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "出力フォルダを開く"
},
"Comfy-Desktop_OpenDevTools": {
"label": "DevToolsを開く"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "デスクトップユーザーガイド"
},
"Comfy-Desktop_Quit": {
"label": "終了"
},
"Comfy-Desktop_Reinstall": {
"label": "再インストール"
},
"Comfy-Desktop_Restart": {
"label": "再起動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "選択したードの3Dビューアーベータを開く"
},
"Comfy_BrowseModelAssets": {
"label": "実験的: モデルアセットを参照"
},
"Comfy_BrowseTemplates": {
"label": "テンプレートを参照"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "サブグラフウィジェットを編集"
},
"Comfy_Graph_ExitSubgraph": {
"label": "サブグラフを終了"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "選択したノードをグループ化"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "ホバー中のウィジェットの優先表示を切り替え"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "選択したサブグラフを展開"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "設定ダイアログを表示"
},
"Comfy_ToggleAssetAPI": {
"label": "実験的: AssetAPIを有効化"
},
"Comfy_ToggleCanvasInfo": {
"label": "キャンバスパフォーマンス"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "サインアウト"
},
"Experimental_ToggleVueNodes": {
"label": "実験的: Vueードを有効化"
},
"Workspace_CloseWorkflow": {
"label": "現在のワークフローを閉じる"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "フォーカスモードの切り替え"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "アセットサイドバーの表示切り替え",
"tooltip": "アセット"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "モデルライブラリサイドバーの切り替え",
"tooltip": "モデルライブラリ"
@@ -298,31 +281,8 @@
"label": "ノードライブラリサイドバーの切り替え",
"tooltip": "ノードライブラリ"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "キューサイドバーの切り替え",
"tooltip": "キュー"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "ワークフローサイドバーの切り替え",
"tooltip": "ワークフロー"
},
"Comfy_BrowseModelAssets": {
"label": "実験的: モデルアセットを参照"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "サブグラフウィジェットを編集"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "ホバー中のウィジェットの優先表示を切り替え"
},
"Comfy_ToggleAssetAPI": {
"label": "実験的: AssetAPIを有効化"
},
"Experimental_ToggleVueNodes": {
"label": "実験的: Vueードを有効化"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "アセットサイドバーの表示切り替え",
"tooltip": "アセット"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "選択したノードのピン留め/ピン留め解除",
"Previous Opened Workflow": "前に開いたワークフロー",
"Publish": "公開",
"Queue Panel": "キューパネル",
"Queue Prompt": "キューのプロンプト",
"Queue Prompt (Front)": "キューのプロンプト (前面)",
"Queue Selected Output Nodes": "選択した出力ノードをキューに追加",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",
"queueTab": {
"backToAllTasks": "すべてのタスクに戻る",
"clearPendingTasks": "保留中のタスクをクリア",
"containImagePreview": "画像プレビューを含める",
"coverImagePreview": "画像プレビューに合わせる",
"filter": "出力をフィルタ",
"filters": {
"hideCached": "キャッシュを非表示",
"hideCanceled": "キャンセル済みを非表示"
},
"showFlatList": "フラットリストを表示"
},
"templates": "テンプレート",
"themeToggle": "テーマを切り替え",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "업데이트 확인"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "커스텀 노드 폴더 열기"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "입력 폴더 열기"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "로그 폴더 열기"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yaml 열기"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "모델 폴더 열기"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "출력 폴더 열기"
},
"Comfy-Desktop_OpenDevTools": {
"label": "DevTools 열기"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "데스크톱 사용자 가이드"
},
"Comfy-Desktop_Quit": {
"label": "종료"
},
"Comfy-Desktop_Reinstall": {
"label": "재설치"
},
"Comfy-Desktop_Restart": {
"label": "재시작"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "선택한 노드에 대해 3D 뷰어(베타) 열기"
},
"Comfy_BrowseModelAssets": {
"label": "실험적: 모델 에셋 탐색"
},
"Comfy_BrowseTemplates": {
"label": "템플릿 탐색"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "서브그래프 위젯 편집"
},
"Comfy_Graph_ExitSubgraph": {
"label": "서브그래프 나가기"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "선택한 노드 그룹화"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "호버링된 위젯 프로모션 전환"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "선택한 서브그래프 묶음 풀기"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "설정 대화상자 보기"
},
"Comfy_ToggleAssetAPI": {
"label": "실험적: AssetAPI 활성화"
},
"Comfy_ToggleCanvasInfo": {
"label": "캔버스 성능"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "로그아웃"
},
"Experimental_ToggleVueNodes": {
"label": "실험적: Vue 노드 활성화"
},
"Workspace_CloseWorkflow": {
"label": "현재 워크플로 닫기"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "포커스 모드 토글"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "에셋 사이드바 전환",
"tooltip": "에셋"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "모델 라이브러리 사이드바 토글",
"tooltip": "모델 라이브러리"
@@ -298,31 +281,8 @@
"label": "노드 라이브러리 사이드바 토글",
"tooltip": "노드 라이브러리"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "실행 큐 사이드바 토글",
"tooltip": "실행 큐"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "워크플로 사이드바 토글",
"tooltip": "워크플로"
},
"Comfy_BrowseModelAssets": {
"label": "실험적: 모델 에셋 탐색"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "서브그래프 위젯 편집"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "호버링된 위젯 프로모션 전환"
},
"Comfy_ToggleAssetAPI": {
"label": "실험적: AssetAPI 활성화"
},
"Experimental_ToggleVueNodes": {
"label": "실험적: Vue 노드 활성화"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "에셋 사이드바 전환",
"tooltip": "에셋"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "선택한 노드 고정/고정 해제",
"Previous Opened Workflow": "이전 열린 워크플로",
"Publish": "게시",
"Queue Panel": "큐 패널",
"Queue Prompt": "실행 대기열에 프롬프트 추가",
"Queue Prompt (Front)": "실행 대기열 맨 앞에 프롬프트 추가",
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",
"queueTab": {
"backToAllTasks": "모든 작업으로 돌아가기",
"clearPendingTasks": "보류 중인 작업 지우기",
"containImagePreview": "이미지 미리보기 채우기",
"coverImagePreview": "이미지 미리보기 맞추기",
"filter": "출력 필터",
"filters": {
"hideCached": "캐시 숨기기",
"hideCanceled": "취소된 작업 숨기기"
},
"showFlatList": "평면 목록 표시"
},
"templates": "템플릿",
"themeToggle": "테마 전환",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Проверить наличие обновлений"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Открыть папку пользовательских нод"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Открыть папку входных данных"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Открыть папку логов"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "Открыть extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Открыть папку моделей"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "Открыть папку результатов"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Открыть инструменты разработчика"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Руководство пользователя для рабочего стола"
},
"Comfy-Desktop_Quit": {
"label": "Выйти"
},
"Comfy-Desktop_Reinstall": {
"label": "Переустановить"
},
"Comfy-Desktop_Restart": {
"label": "Перезагрузить"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Открыть 3D-просмотрщик (бета) для выбранного узла"
},
"Comfy_BrowseModelAssets": {
"label": "Экспериментально: Просмотр ресурсов моделей"
},
"Comfy_BrowseTemplates": {
"label": "Просмотр шаблонов"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Редактировать виджеты подграфов"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Выйти из подграфа"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Группировать выбранные ноды"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Переключить продвижение наведенного виджета"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Распаковать выбранный подграф"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Показать диалог настроек"
},
"Comfy_ToggleAssetAPI": {
"label": "Экспериментально: Включить AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "Производительность холста"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "Выйти"
},
"Experimental_ToggleVueNodes": {
"label": "Экспериментально: Включить Vue узлы"
},
"Workspace_CloseWorkflow": {
"label": "Закрыть текущий рабочий процесс"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Переключить режим фокуса"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Переключить боковую панель ресурсов",
"tooltip": "Ресурсы"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Переключить боковую панель библиотеки моделей",
"tooltip": "Библиотека моделей"
@@ -298,31 +281,8 @@
"label": "Переключить боковую панель библиотеки нод",
"tooltip": "Библиотека нод"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Переключить боковую панель очереди",
"tooltip": "Очередь"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "Переключить боковую панель рабочих процессов",
"tooltip": "Рабочие процессы"
},
"Comfy_BrowseModelAssets": {
"label": "Экспериментально: Просмотр ресурсов моделей"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Редактировать виджеты подграфов"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Переключить продвижение наведенного виджета"
},
"Comfy_ToggleAssetAPI": {
"label": "Экспериментально: Включить AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "Экспериментально: Включить Vue узлы"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Переключить боковую панель ресурсов",
"tooltip": "Ресурсы"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Закрепить/открепить выбранные ноды",
"Previous Opened Workflow": "Предыдущий открытый рабочий процесс",
"Publish": "Опубликовать",
"Queue Panel": "Панель очереди",
"Queue Prompt": "Запрос в очереди",
"Queue Prompt (Front)": "Запрос в очереди (спереди)",
"Queue Selected Output Nodes": "Добавить выбранные выходные узлы в очередь",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",
"queueTab": {
"backToAllTasks": "Вернуться ко всем задачам",
"clearPendingTasks": "Очистить отложенные задачи",
"containImagePreview": "Предпросмотр заливающего изображения",
"coverImagePreview": "Предпросмотр подходящего изображения",
"filter": "Фильтровать выводы",
"filters": {
"hideCached": "Скрыть кэшированные",
"hideCanceled": "Скрыть отмененные"
},
"showFlatList": "Показать плоский список"
},
"templates": "Шаблоны",
"themeToggle": "Переключить тему",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "Güncellemeleri Kontrol Et"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "Özel Düğümler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "Girişler Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "Kayıtlar Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "extra_model_paths.yaml dosyasını aç"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "Modeller Klasörünü Aç"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": ıktılar Klasörünü Aç"
},
"Comfy-Desktop_OpenDevTools": {
"label": "Geliştirici Araçlarını Aç"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "Masaüstü Kullanıcı Kılavuzu"
},
"Comfy-Desktop_Quit": {
"label": ık"
},
"Comfy-Desktop_Reinstall": {
"label": "Yeniden Yükle"
},
"Comfy-Desktop_Restart": {
"label": "Yeniden Başlat"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "Seçili Düğüm için 3D Görüntüleyiciyi (Beta) Aç"
},
"Comfy_BrowseModelAssets": {
"label": "Deneysel: Model Varlıklarını Gözat"
},
"Comfy_BrowseTemplates": {
"label": "Şablonlara Gözat"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Seçimi Alt Grafiğe Dönüştür"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Alt Grafik Bileşenlerini Düzenle"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Alt Grafikten Çık"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Seçili Düğümleri Gruplandır"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Vurgulanan bileşenin önceliğini değiştir"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Seçili Alt Grafiği Aç"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "Ayarlar İletişim Kutusunu Göster"
},
"Comfy_ToggleAssetAPI": {
"label": "Deneysel: AssetAPI'yi Etkinleştir"
},
"Comfy_ToggleCanvasInfo": {
"label": "Tuval Performansı"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": ıkış Yap"
},
"Experimental_ToggleVueNodes": {
"label": "Deneysel: Vue Düğümlerini Etkinleştir"
},
"Workspace_CloseWorkflow": {
"label": "Mevcut İş Akışını Kapat"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "Odak Modunu Aç/Kapat"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Varlıklar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Varlıklar"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "Model Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Model Kütüphanesi"
@@ -298,31 +281,8 @@
"label": "Düğüm Kütüphanesi Kenar Çubuğunu Aç/Kapat",
"tooltip": "Düğüm Kütüphanesi"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "Kuyruk Kenar Çubuğunu Aç/Kapat",
"tooltip": "Kuyruk"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "İş Akışları Kenar Çubuğunu Aç/Kapat",
"tooltip": "İş Akışları"
},
"Comfy_BrowseModelAssets": {
"label": "Deneysel: Model Varlıklarını Gözat"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Alt Grafik Bileşenlerini Düzenle"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Vurgulanan bileşenin önceliğini değiştir"
},
"Comfy_ToggleAssetAPI": {
"label": "Deneysel: AssetAPI'yi Etkinleştir"
},
"Experimental_ToggleVueNodes": {
"label": "Deneysel: Vue Düğümlerini Etkinleştir"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "Varlıklar Kenar Çubuğunu Aç/Kapat",
"tooltip": "Varlıklar"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "Seçili Düğümleri Sabitle/Kaldır",
"Previous Opened Workflow": "Önceki Açılan İş Akışı",
"Publish": "Yayınla",
"Queue Panel": "Kuyruk Paneli",
"Queue Prompt": "İstemi Kuyruğa Al",
"Queue Prompt (Front)": "İstemi Kuyruğa Al (Ön)",
"Queue Selected Output Nodes": "Seçili Çıktı Düğümlerini Kuyruğa Al",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "Yerel dosya sisteminde iş akışını aç",
"queue": "Kuyruk",
"queueTab": {
"backToAllTasks": "Tüm Görevlere Geri Dön",
"clearPendingTasks": "Bekleyen Görevleri Temizle",
"containImagePreview": "Resim Önizlemesini Doldur",
"coverImagePreview": "Resim Önizlemesine Sığdır",
"filter": ıktıları Filtrele",
"filters": {
"hideCached": "Önbelleğe Alınanları Gizle",
"hideCanceled": "İptal Edilenleri Gizle"
},
"showFlatList": "Düz Listeyi Göster"
},
"templates": "Şablonlar",
"themeToggle": "Temayı Değiştir",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "檢查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "開啟自訂節點資料夾"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "開啟輸入資料夾"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "開啟日誌資料夾"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "開啟 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "開啟模型資料夾"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "開啟輸出資料夾"
},
"Comfy-Desktop_OpenDevTools": {
"label": "開啟開發者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面版使用指南"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重新安裝"
},
"Comfy-Desktop_Restart": {
"label": "重新啟動"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "為選取的節點開啟 3D 檢視器Beta"
},
"Comfy_BrowseModelAssets": {
"label": "實驗性:瀏覽模型資源"
},
"Comfy_BrowseTemplates": {
"label": "瀏覽範本"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "將選取內容轉換為子圖"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "編輯子圖表小工具"
},
"Comfy_Graph_ExitSubgraph": {
"label": "離開子圖"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "群組所選節點"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切換懸停小工具的提升"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "解開所選子圖"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "顯示設定對話框"
},
"Comfy_ToggleAssetAPI": {
"label": "實驗性:啟用 AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "畫布效能"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "登出"
},
"Experimental_ToggleVueNodes": {
"label": "實驗性:啟用 Vue 節點"
},
"Workspace_CloseWorkflow": {
"label": "關閉當前工作流程"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "切換專注模式"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切換資源側邊欄",
"tooltip": "資源"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "切換模型庫側邊欄",
"tooltip": "模型庫"
@@ -298,31 +281,8 @@
"label": "切換節點庫側邊欄",
"tooltip": "節點庫"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切換佇列側邊欄",
"tooltip": "佇列"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "切換工作流程側邊欄",
"tooltip": "工作流程"
},
"Comfy_BrowseModelAssets": {
"label": "實驗性:瀏覽模型資源"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "編輯子圖表小工具"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切換懸停小工具的提升"
},
"Comfy_ToggleAssetAPI": {
"label": "實驗性:啟用 AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "實驗性:啟用 Vue 節點"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切換資源側邊欄",
"tooltip": "資源"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "釘選/取消釘選選取節點",
"Previous Opened Workflow": "上一個已開啟的工作流程",
"Publish": "發佈",
"Queue Panel": "佇列面板",
"Queue Prompt": "加入提示至佇列",
"Queue Prompt (Front)": "將提示加入佇列前端",
"Queue Selected Output Nodes": "將選取的輸出節點加入佇列",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "在本機檔案系統中開啟工作流程",
"queue": "佇列",
"queueTab": {
"backToAllTasks": "返回所有任務",
"clearPendingTasks": "清除待處理任務",
"containImagePreview": "填滿圖片預覽",
"coverImagePreview": "適合圖片預覽",
"filter": "篩選輸出",
"filters": {
"hideCached": "隱藏快取",
"hideCanceled": "隱藏已取消"
},
"showFlatList": "顯示平面清單"
},
"templates": "範本",
"themeToggle": "切換主題",
"workflowTab": {

View File

@@ -1,43 +1,10 @@
{
"Comfy-Desktop_CheckForUpdates": {
"label": "检查更新"
},
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
"label": "打开自定义节点文件夹"
},
"Comfy-Desktop_Folders_OpenInputsFolder": {
"label": "打开输入文件夹"
},
"Comfy-Desktop_Folders_OpenLogsFolder": {
"label": "打开日志文件夹"
},
"Comfy-Desktop_Folders_OpenModelConfig": {
"label": "打开 extra_model_paths.yaml"
},
"Comfy-Desktop_Folders_OpenModelsFolder": {
"label": "打开模型文件夹"
},
"Comfy-Desktop_Folders_OpenOutputsFolder": {
"label": "打开输出文件夹"
},
"Comfy-Desktop_OpenDevTools": {
"label": "打开开发者工具"
},
"Comfy-Desktop_OpenUserGuide": {
"label": "桌面用户指南"
},
"Comfy-Desktop_Quit": {
"label": "退出"
},
"Comfy-Desktop_Reinstall": {
"label": "重新安装"
},
"Comfy-Desktop_Restart": {
"label": "重启"
},
"Comfy_3DViewer_Open3DViewer": {
"label": "为所选节点开启 3D 浏览器Beta 版)"
},
"Comfy_BrowseModelAssets": {
"label": "实验性:浏览模型资源"
},
"Comfy_BrowseTemplates": {
"label": "浏览模板"
},
@@ -125,6 +92,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "编辑子图组件"
},
"Comfy_Graph_ExitSubgraph": {
"label": "退出子图"
},
@@ -134,6 +104,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "添加框到选中节点"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切换悬停小部件的推广"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "解开所选子图"
},
@@ -239,6 +212,9 @@
"Comfy_ShowSettingsDialog": {
"label": "显示设置对话框"
},
"Comfy_ToggleAssetAPI": {
"label": "实验性:启用 AssetAPI"
},
"Comfy_ToggleCanvasInfo": {
"label": "画布性能"
},
@@ -257,6 +233,9 @@
"Comfy_User_SignOut": {
"label": "退出登录"
},
"Experimental_ToggleVueNodes": {
"label": "实验性:启用 Vue 节点"
},
"Workspace_CloseWorkflow": {
"label": "关闭当前工作流"
},
@@ -290,6 +269,10 @@
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切换资产侧边栏",
"tooltip": "资产"
},
"Workspace_ToggleSidebarTab_model-library": {
"label": "切换模型库侧边栏",
"tooltip": "模型库"
@@ -298,31 +281,8 @@
"label": "切换节点库侧边栏",
"tooltip": "节点库"
},
"Workspace_ToggleSidebarTab_queue": {
"label": "切换执行队列侧边栏",
"tooltip": "执行队列"
},
"Workspace_ToggleSidebarTab_workflows": {
"label": "切换工作流侧边栏",
"tooltip": "工作流"
},
"Comfy_BrowseModelAssets": {
"label": "实验性:浏览模型资源"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "编辑子图组件"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "切换悬停小部件的推广"
},
"Comfy_ToggleAssetAPI": {
"label": "实验性:启用 AssetAPI"
},
"Experimental_ToggleVueNodes": {
"label": "实验性:启用 Vue 节点"
},
"Workspace_ToggleSidebarTab_assets": {
"label": "切换资产侧边栏",
"tooltip": "资产"
}
}

View File

@@ -1210,7 +1210,6 @@
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
"Previous Opened Workflow": "上一个打开的工作流",
"Publish": "发布",
"Queue Panel": "队列面板",
"Queue Prompt": "执行提示词",
"Queue Prompt (Front)": "执行提示词 (优先执行)",
"Queue Selected Output Nodes": "将所选输出节点加入队列",
@@ -1670,18 +1669,6 @@
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",
"queueTab": {
"backToAllTasks": "返回",
"clearPendingTasks": "清除待处理任务",
"containImagePreview": "填充图像预览",
"coverImagePreview": "适应图像预览",
"filter": "过滤输出",
"filters": {
"hideCached": "隐藏缓存",
"hideCanceled": "隐藏已取消"
},
"showFlatList": "平铺结果"
},
"templates": "模板",
"themeToggle": "切换主题",
"workflowTab": {

View File

@@ -73,11 +73,14 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -92,6 +95,7 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
@@ -189,6 +193,15 @@ const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function handleUploadClick() {
// Will be implemented in the future commit
dialogStore.showDialog({
key: 'upload-model',
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute()
}
}
})
}
</script>

View File

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

View File

@@ -0,0 +1,108 @@
<template>
<div class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6">
<!-- Step 1: Enter URL -->
<UploadModelUrlInput
v-if="currentStep === 1"
v-model="wizardData.url"
:error="uploadError"
/>
<!-- Step 2: Confirm Metadata -->
<UploadModelConfirmation
v-else-if="currentStep === 2"
v-model="selectedModelType"
:metadata="wizardData.metadata"
/>
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3"
:status="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"
/>
<!-- Navigation Footer -->
<UploadModelFooter
:current-step="currentStep"
:is-fetching-metadata="isFetchingMetadata"
:is-uploading="isUploading"
:can-fetch-metadata="canFetchMetadata"
:can-upload-model="canUploadModel"
:upload-status="uploadStatus"
@back="goToPreviousStep"
@fetch-metadata="handleFetchMetadata"
@upload="handleUploadModel"
@close="handleClose"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import UploadModelConfirmation from '@/platform/assets/components/UploadModelConfirmation.vue'
import UploadModelFooter from '@/platform/assets/components/UploadModelFooter.vue'
import UploadModelProgress from '@/platform/assets/components/UploadModelProgress.vue'
import UploadModelUrlInput from '@/platform/assets/components/UploadModelUrlInput.vue'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useUploadModelWizard } from '@/platform/assets/composables/useUploadModelWizard'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const { modelTypes, fetchModelTypes } = useModelTypes()
const emit = defineEmits<{
'upload-success': []
}>()
const {
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
canFetchMetadata,
canUploadModel,
fetchMetadata,
uploadModel,
goToPreviousStep
} = useUploadModelWizard(modelTypes)
async function handleFetchMetadata() {
await fetchMetadata()
}
async function handleUploadModel() {
const success = await uploadModel()
if (success) {
emit('upload-success')
}
}
function handleClose() {
dialogStore.closeDialog({ key: 'upload-model' })
}
onMounted(() => {
fetchModelTypes()
})
</script>
<style scoped>
.upload-model-dialog {
width: 90vw;
max-width: 800px;
min-height: 400px;
}
@media (min-width: 640px) {
.upload-model-dialog {
width: auto;
min-width: 600px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex justify-end gap-2">
<TextButton
v-if="currentStep !== 1 && currentStep !== 3"
:label="$t('g.back')"
type="secondary"
size="md"
:disabled="isFetchingMetadata || isUploading"
@click="emit('back')"
/>
<span v-else />
<IconTextButton
v-if="currentStep === 1"
:label="$t('g.continue')"
type="primary"
size="md"
:disabled="!canFetchMetadata || isFetchingMetadata"
@click="emit('fetchMetadata')"
>
<template #icon>
<i
v-if="isFetchingMetadata"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<IconTextButton
v-else-if="currentStep === 2"
:label="$t('assetBrowser.upload')"
type="primary"
size="md"
:disabled="!canUploadModel || isUploading"
@click="emit('upload')"
>
<template #icon>
<i
v-if="isUploading"
class="icon-[lucide--loader-circle] animate-spin"
/>
</template>
</IconTextButton>
<TextButton
v-else-if="currentStep === 3 && uploadStatus === 'success'"
:label="$t('assetBrowser.finish')"
type="primary"
size="md"
@click="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
defineProps<{
currentStep: number
isFetchingMetadata: boolean
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'fetchMetadata'): void
(e: 'upload'): void
(e: 'close'): void
}>()
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div class="flex flex-1 flex-col gap-6">
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-primary"
/>
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="status === 'success'" class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<p class="text-sm text-muted m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.findInLibrary', { type: modelType }) }}
</p>
</div>
<div class="flex flex-row items-start p-8 bg-neutral-800 rounded-lg">
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-sm m-0">
{{ metadata?.name || metadata?.filename }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
</div>
</div>
<!-- Error State -->
<div
v-else-if="status === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
<div class="text-center">
<p class="m-0 text-sm font-bold">
{{ $t('assetBrowser.uploadFailed') }}
</p>
<p v-if="error" class="text-sm text-muted mb-0">
{{ error }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata: AssetMetadata | null
modelType: string | undefined
}>()
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-sm text-muted m-0">
{{ $t('assetBrowser.uploadModelDescription1') }}
</p>
<ul class="list-disc space-y-1 pl-5 mt-0 text-sm text-muted">
<li>{{ $t('assetBrowser.uploadModelDescription2') }}</li>
<li>{{ $t('assetBrowser.uploadModelDescription3') }}</li>
</ul>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted mb-0">
{{ $t('assetBrowser.civitaiLinkLabel') }}
</label>
<UrlInput
v-model="url"
:placeholder="$t('assetBrowser.civitaiLinkPlaceholder')"
:disable-validation="true"
/>
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else class="text-xs text-muted">
{{ $t('assetBrowser.civitaiLinkExample') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
const props = defineProps<{
modelValue: string
error?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const url = computed({
get: () => props.modelValue,
set: (value: string) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,73 @@
import { createSharedComposable, useAsyncState } from '@vueuse/core'
import { api } from '@/scripts/api'
/**
* Format folder name to display name
* Converts "upscale_models" -> "Upscale Models"
* Converts "loras" -> "LoRAs"
*/
function formatDisplayName(folderName: string): string {
// Special cases for acronyms and proper nouns
const specialCases: Record<string, string> = {
loras: 'LoRAs',
ipadapter: 'IP-Adapter',
sams: 'SAMs',
clip_vision: 'CLIP Vision',
animatediff_motion_lora: 'AnimateDiff Motion LoRA',
animatediff_models: 'AnimateDiff Models',
vae: 'VAE',
sam2: 'SAM 2',
controlnet: 'ControlNet',
gligen: 'GLIGEN'
}
if (specialCases[folderName]) {
return specialCases[folderName]
}
return folderName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
interface ModelTypeOption {
name: string // Display name
value: string // Actual tag value
}
/**
* Composable for fetching and managing model types from the API
* Uses shared state to ensure data is only fetched once
*/
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isLoading,
error,
execute: fetchModelTypes
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
return response.map((folder) => ({
name: formatDisplayName(folder.name),
value: folder.name
}))
},
[] as ModelTypeOption[],
{
immediate: false,
onError: (err) => {
console.error('Failed to fetch model types:', err)
}
}
)
return {
modelTypes,
isLoading,
error,
fetchModelTypes
}
})

View File

@@ -0,0 +1,175 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { st } from '@/i18n'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
interface WizardData {
url: string
metadata: AssetMetadata | null
name: string
tags: string[]
}
interface ModelTypeOption {
name: string
value: string
}
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadError = ref('')
const wizardData = ref<WizardData>({
url: '',
metadata: null,
name: '',
tags: []
})
const selectedModelType = ref<string | undefined>(undefined)
// Clear error when URL changes
watch(
() => wizardData.value.url,
() => {
uploadError.value = ''
}
)
// Validation
const canFetchMetadata = computed(() => {
return wizardData.value.url.trim().length > 0
})
const canUploadModel = computed(() => {
return !!selectedModelType.value
})
function isCivitaiUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'civitai.com' || hostname.endsWith('.civitai.com')
} catch {
return false
}
}
async function fetchMetadata() {
if (!canFetchMetadata.value) return
if (!isCivitaiUrl(wizardData.value.url)) {
uploadError.value = st(
'assetBrowser.onlyCivitaiUrlsSupported',
'Only Civitai URLs are supported'
)
return
}
isFetchingMetadata.value = true
try {
const metadata = await assetService.getAssetMetadata(wizardData.value.url)
wizardData.value.metadata = metadata
// Pre-fill name from metadata
wizardData.value.name = metadata.filename || metadata.name || ''
// Pre-fill model type from metadata tags if available
if (metadata.tags && metadata.tags.length > 0) {
wizardData.value.tags = metadata.tags
// Try to detect model type from tags
const typeTag = metadata.tags.find((tag) =>
modelTypes.value.some((type) => type.value === tag)
)
if (typeTag) {
selectedModelType.value = typeTag
}
}
currentStep.value = 2
} catch (error) {
console.error('Failed to retrieve metadata:', error)
uploadError.value =
error instanceof Error
? error.message
: st(
'assetBrowser.uploadModelFailedToRetrieveMetadata',
'Failed to retrieve metadata. Please check the link and try again.'
)
currentStep.value = 1
} finally {
isFetchingMetadata.value = false
}
}
async function uploadModel() {
if (!canUploadModel.value) return
isUploading.value = true
uploadStatus.value = 'uploading'
try {
const tags = selectedModelType.value
? ['models', selectedModelType.value]
: ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||
'model'
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: {
source: 'civitai',
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
})
uploadStatus.value = 'success'
currentStep.value = 3
return true
} catch (error) {
console.error('Failed to upload asset:', error)
uploadStatus.value = 'error'
uploadError.value =
error instanceof Error ? error.message : 'Failed to upload model'
currentStep.value = 3
return false
} finally {
isUploading.value = false
}
}
function goToPreviousStep() {
if (currentStep.value > 1) {
currentStep.value = currentStep.value - 1
}
}
return {
// State
currentStep,
isFetchingMetadata,
isUploading,
uploadStatus,
uploadError,
wizardData,
selectedModelType,
// Computed
canFetchMetadata,
canUploadModel,
// Actions
fetchMetadata,
uploadModel,
goToPreviousStep
}
}

View File

@@ -33,6 +33,29 @@ const zModelFile = z.object({
pathIndex: z.number()
})
const zValidationError = z.object({
code: z.string(),
message: z.string(),
field: z.string()
})
const zValidationResult = z.object({
is_valid: z.boolean(),
errors: z.array(zValidationError).optional(),
warnings: z.array(zValidationError).optional()
})
const zAssetMetadata = z.object({
content_length: z.number(),
final_url: z.string(),
content_type: z.string().optional(),
filename: z.string().optional(),
name: z.string().optional(),
tags: z.array(z.string()).optional(),
preview_url: z.string().optional(),
validation: zValidationResult.optional()
})
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -48,6 +71,7 @@ export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -1,8 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
ModelFile,
ModelFolder
@@ -10,6 +12,36 @@ import type {
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
/**
* Maps CivitAI validation error codes to localized error messages
*/
function getLocalizedErrorMessage(errorCode: string): string {
const errorMessages: Record<string, string> = {
FILE_TOO_LARGE: st('assetBrowser.errorFileTooLarge', 'File too large'),
FORMAT_NOT_ALLOWED: st(
'assetBrowser.errorFormatNotAllowed',
'Format not allowed'
),
UNSAFE_PICKLE_SCAN: st(
'assetBrowser.errorUnsafePickleScan',
'Unsafe pickle scan'
),
UNSAFE_VIRUS_SCAN: st(
'assetBrowser.errorUnsafeVirusScan',
'Unsafe virus scan'
),
MODEL_TYPE_NOT_SUPPORTED: st(
'assetBrowser.errorModelTypeNotSupported',
'Model type not supported'
)
}
return (
errorMessages[errorCode] ||
st('assetBrowser.errorUnknown', 'Unknown error') ||
'Unknown error'
)
}
const ASSETS_ENDPOINT = '/assets'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -249,6 +281,77 @@ function createAssetService() {
}
}
/**
* Retrieves metadata from a download URL without downloading the file
*
* @param url - Download URL to retrieve metadata from (will be URL-encoded)
* @returns Promise with metadata including content_length, final_url, filename, etc.
* @throws Error if metadata retrieval fails
*/
async function getAssetMetadata(url: string): Promise<AssetMetadata> {
const encodedUrl = encodeURIComponent(url)
const res = await api.fetchApi(
`${ASSETS_ENDPOINT}/remote-metadata?url=${encodedUrl}`
)
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))
throw new Error(
getLocalizedErrorMessage(errorData.code || 'UNKNOWN_ERROR')
)
}
const data: AssetMetadata = await res.json()
if (data.validation?.is_valid === false) {
throw new Error(
getLocalizedErrorMessage(
data.validation?.errors?.[0]?.code || 'UNKNOWN_ERROR'
)
)
}
return data
}
/**
* Uploads an asset by providing a URL to download from
*
* @param params - Upload parameters
* @param params.url - HTTP/HTTPS URL to download from
* @param params.name - Display name (determines extension)
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @param params.preview_id - Optional UUID for preview asset
* @returns Promise<AssetItem & { created_new: boolean }> - Asset object with created_new flag
* @throws Error if upload fails
*/
async function uploadAssetFromUrl(params: {
url: string
name: string
tags?: string[]
user_metadata?: Record<string, any>
preview_id?: string
}): Promise<AssetItem & { created_new: boolean }> {
const res = await api.fetchApi(ASSETS_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -256,7 +359,9 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
deleteAsset
deleteAsset,
getAssetMetadata,
uploadAssetFromUrl
}
}

View File

@@ -13,6 +13,7 @@ import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
@@ -329,6 +330,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
tabActivationHistory.value.shift()
}
useCanvasStore().linearMode = !!loadedWorkflow.activeState.extra?.linearMode
return loadedWorkflow
}

View File

@@ -40,6 +40,8 @@ export const useCanvasStore = defineStore('canvas', () => {
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
const linearMode = ref(false)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
undefined
@@ -138,6 +140,7 @@ export const useCanvasStore = defineStore('canvas', () => {
groupSelected,
rerouteSelected,
appScalePercentage,
linearMode,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,

View File

@@ -67,7 +67,9 @@ import type {
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useLivePreview } from '@/composables/useLivePreview'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
@@ -83,12 +85,16 @@ import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
const { propagateLivePreview } = useLivePreview()
interface NodeWidgetsProps {
nodeData?: VueNodeData
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeManager } = useVueNodeLifecycle()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
function handleWidgetPointerEvent(event: PointerEvent) {
@@ -113,6 +119,24 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
function propagateToDownstreamVue(
sourceNodeId: string,
widgetName: string,
value: WidgetValue
): void {
const lgNode = nodeManager.value?.getNode(sourceNodeId)
if (!lgNode || !value) {
return
}
propagateLivePreview(lgNode, value, {
outputName: widgetName,
updateWidget: true,
callWidgetCallback: false,
callOnExecuted: false
})
}
interface ProcessedWidget {
name: string
type: string
@@ -170,6 +194,10 @@ const processedWidgets = computed((): ProcessedWidget[] => {
if (widget.type !== 'asset') {
widget.callback?.(value)
}
if (nodeData?.id && nodeManager.value) {
propagateToDownstreamVue(nodeData.id, widget.name, value)
}
}
const tooltipText = getWidgetTooltip(widget)

View File

@@ -14,6 +14,7 @@ import type { MaybeRefOrGetter } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -60,6 +61,7 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
if (useCanvasStore().linearMode) return
// Canvas is ready when this code runs; no defensive guards needed.
const conv = useSharedCanvasPositionConversion()
// Group updates by type, then flush via each config's handler

View File

@@ -4,6 +4,10 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { useLivePreview } from '@/composables/useLivePreview'
const { propagateLivePreview } = useLivePreview()
const TRACKPAD_DETECTION_THRESHOLD = 50
@@ -119,6 +123,22 @@ export const useStringWidget = () => {
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const propagateCallback = (value: WidgetValue) => {
if (!value) {
return
}
// Simple propagation: just send the value downstream
// - Nodes with calculators will automatically recalculate
// - Passive nodes (like PreviewAny) will receive onExecuted
propagateLivePreview(node, value, {
outputName: inputSpec.name,
setOutputData: true,
updateWidget: true,
callOnExecuted: true
})
}
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
@@ -130,6 +150,23 @@ export const useStringWidget = () => {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
const originalCallback = widget.callback
widget.callback = function (value: WidgetValue) {
if (originalCallback) {
;(originalCallback as any).call(this, value)
}
const input = node.inputs?.find(
(input) => input.widget?.name === inputSpec.name
)
if (input?.link) {
return
}
propagateCallback(value)
}
return widget
}

View File

@@ -230,6 +230,18 @@ export const zComfyNodeDef = z.object({
input_order: z.record(z.array(z.string())).optional()
})
export const zDynamicComboInputSpec = z.tuple([
z.literal('COMFY_DYNAMICCOMBO_V3'),
zComboInputOptions.extend({
options: z.array(
z.object({
inputs: zComfyInputsSpec,
key: z.string()
})
)
})
])
// `/object_info`
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>

View File

@@ -6,6 +6,7 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -296,5 +297,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
...dynamicWidgets
}

View File

@@ -304,11 +304,13 @@ export const useDialogService = () => {
async function prompt({
title,
message,
defaultValue = ''
defaultValue = '',
placeholder
}: {
title: string
message: string
defaultValue?: string
placeholder?: string
}): Promise<string | null> {
return new Promise((resolve) => {
dialogStore.showDialog({
@@ -320,7 +322,8 @@ export const useDialogService = () => {
defaultValue,
onConfirm: (value: string) => {
resolve(value)
}
},
placeholder
},
dialogComponentProps: {
onClose: () => {

View File

@@ -33,6 +33,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
ComfyNodeDef as ComfyNodeDefV2,
@@ -59,6 +60,10 @@ import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'
export interface HasInitialMinSize {
_initialMinSize: { width: number; height: number }
}
export const CONFIG = Symbol()
export const GET_CONFIG = Symbol()
@@ -72,28 +77,184 @@ export const useLitegraphService = () => {
const canvasStore = useCanvasStore()
const { toggleSelectedNodesMode } = useSelectedLiteGraphItems()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
/**
* @internal The key for the node definition in the i18n file.
*/
function nodeKey(node: LGraphNode): string {
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
}
/**
* @internal Add input sockets to the node. (No widget)
*/
function addInputSocket(node: LGraphNode, inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
node.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
function setupStrokeStyles(node: LGraphNode) {
node.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
node.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
node.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
node.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* Utility function. Implemented for use with dynamic widgets
*/
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
addInputSocket(node, inputSpec)
addInputWidget(node, inputSpec)
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${nodeKey(node)}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
node,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
node.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
const castedNode = node as LGraphNode & HasInitialMinSize
castedNode._initialMinSize.width = Math.max(
castedNode._initialMinSize.width,
minWidth
)
castedNode._initialMinSize.height = Math.max(
castedNode._initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
function addInputs(node: LGraphNode, inputs: Record<string, InputSpec>) {
// Use input_order if available to ensure consistent widget ordering
//@ts-expect-error was ComfyNode.nodeData as ComfyNodeDefImpl
const nodeDefImpl = node.constructor.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs) addInputSocket(node, inputSpec)
for (const inputSpec of orderedInputSpecs) addInputWidget(node, inputSpec)
}
/**
* @internal Add outputs to the node.
*/
function addOutputs(node: LGraphNode, outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${nodeKey(node)}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name: type !== name ? st(nameKey, name) : st(typeKey, name)
}
node.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
function setInitialSize(node: LGraphNode) {
const s = node.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
node.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
const castedNode = node as LGraphNode & HasInitialMinSize
s[0] = Math.max(castedNode._initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(castedNode._initialMinSize.height, s[1])
node.setSize(s)
}
function registerSubgraphNodeDef(
nodeDefV1: ComfyNodeDefV1,
subgraph: Subgraph,
instanceData: ExportedSubgraphInstance
) {
const node = class ComfyNode extends SubgraphNode {
const node = class ComfyNode
extends SubgraphNode
implements HasInitialMinSize
{
static comfyClass: string
static override title: string
static override category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
_initialMinSize = { width: 1, height: 1 }
constructor() {
super(app.graph, subgraph, instanceData)
@@ -129,165 +290,14 @@ export const useLitegraphService = () => {
}
})
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
setupStrokeStyles(this)
addInputs(this, ComfyNode.nodeData.inputs)
addOutputs(this, ComfyNode.nodeData.outputs)
setInitialSize(this)
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
this.#addInputSocket(inputSpec)
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
this.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
}
/**
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
* and 'localized_name' information from the original node definition.
@@ -368,29 +378,23 @@ export const useLitegraphService = () => {
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
const node = class ComfyNode
extends LGraphNode
implements HasInitialMinSize
{
static comfyClass: string
static override title: string
static override category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
_initialMinSize = { width: 1, height: 1 }
constructor(title: string) {
super(title)
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
setupStrokeStyles(this)
addInputs(this, ComfyNode.nodeData.inputs)
addOutputs(this, ComfyNode.nodeData.outputs)
setInitialSize(this)
this.serialize_widgets = true
// Mark API Nodes yellow by default to distinguish with other nodes.
@@ -402,168 +406,6 @@ export const useLitegraphService = () => {
void extensionService.invokeExtensionsAsync('nodeCreated', this)
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
// Check if this is an Asset Browser button widget
const isAssetBrowserButton =
widget.type === 'button' && widget.value === 'Select model'
if (isAssetBrowserButton) {
// Preserve Asset Browser button label (don't translate)
widget.label = String(widget.value)
} else {
// Apply normal translation for other widgets
widget.label = st(nameKey, widget.label ?? inputName)
}
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
// Use input_order if available to ensure consistent widget ordering
const nodeDefImpl = ComfyNode.nodeData as ComfyNodeDefImpl
const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs)
// Create sockets and widgets in the determined order
for (const inputSpec of orderedInputSpecs)
this.#addInputSocket(inputSpec)
for (const inputSpec of orderedInputSpecs)
this.#addInputWidget(inputSpec)
}
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
this.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
}
/**
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
* and 'localized_name' information from the original node definition.
@@ -822,14 +664,32 @@ export const useLitegraphService = () => {
}
)
}
if (this.graph && !this.graph.isRootGraph) {
const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
const input = this.inputs.find(
(inp) => inp.widget?.name === overWidget.name
)
if (input)
options.unshift({
content: `${t('contextMenu.RenameWidget')}: ${overWidget.label ?? overWidget.name}`,
callback: async () => {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewName') + ':',
defaultValue: overWidget.label,
placeholder: overWidget.name
})
if (newLabel === null) return
overWidget.label = newLabel || undefined
input.label = newLabel || undefined
useCanvasStore().canvas?.setDirty(true)
}
})
if (this.graph && !this.graph.isRootGraph) {
addWidgetPromotionOptions(options, overWidget, this)
}
}
return []
}
}
@@ -1031,6 +891,7 @@ export const useLitegraphService = () => {
registerNodeDef,
registerSubgraphNodeDef,
addNodeOnGraph,
addNodeInput,
getCanvasCenter,
goToNode,
resetView,

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