Compare commits

...

29 Commits

Author SHA1 Message Date
Chenlei Hu
244578db96 1.4.12 (#1706) 2024-11-26 14:02:43 -05:00
Chenlei Hu
6b6edfde9f chore: update litegraph to 0.8.37 (#1704) 2024-11-26 13:20:21 -05:00
Chenlei Hu
c54b675a48 Revert Filter cached/canceled results (#1586) (#1703)
* Revert "Filter cached/canceled results (#1586)"

This reverts commit 6fbf1248f4.

* nit
2024-11-26 13:17:26 -05:00
Chenlei Hu
b7008dfc5c Revert "nit: Fix import of OutputFilters in queue sidebar (#1680)" (#1702)
This reverts commit f97b673481.
2024-11-26 13:11:28 -05:00
Chenlei Hu
d0ad4af51c Revert "Move queueStore update to GraphView (#1679)" (#1701)
This reverts commit c8d5a6f154.
2024-11-26 13:08:42 -05:00
Chenlei Hu
4a182014e1 Revert "Fix queue sidebar tab task filter (#1682)" (#1700)
This reverts commit c1c5573e7f.
2024-11-26 13:08:19 -05:00
Chenlei Hu
46cd522384 Fix save temporary workflow loop on overwrite (#1699) 2024-11-26 10:44:25 -05:00
Hayden
c977667a15 Change dialog to multi-window mode (#1695)
Fixed Dropdown's z-index being below the dialog
2024-11-26 10:11:15 -05:00
Chenlei Hu
d531bc34c4 Make ChangeTracker detect changes in workflow.extra (Except ds) (#1692)
* Compare workflow.extra content

* Add tests
2024-11-25 21:59:27 -05:00
Chenlei Hu
adfbec2744 Add setting to adjust queue MaxHistoryItems (#1689)
* Add MaxHistoryItems

* nit
2024-11-25 18:49:40 -05:00
Chenlei Hu
23521559bf 1.4.11 (#1688) 2024-11-25 18:43:17 -05:00
Chenlei Hu
51f57aba17 Revert "Change dialog to multi-window mode (#1672)" (#1686)
This reverts commit 43c23e526c.
2024-11-25 13:29:47 -05:00
Chenlei Hu
97bab053df Split i18n locales to multiple files (#1683) 2024-11-25 13:18:14 -05:00
Chenlei Hu
c1c5573e7f Fix queue sidebar tab task filter (#1682)
* Fix queue sidebar tab task filter

* nit
2024-11-25 12:59:10 -05:00
Chenlei Hu
16d2a95760 chore: update litegraph to 0.8.36 (#1681) 2024-11-25 11:55:42 -05:00
Chenlei Hu
f97b673481 nit: Fix import of OutputFilters in queue sidebar (#1680) 2024-11-25 11:49:49 -05:00
Chenlei Hu
c8d5a6f154 Move queueStore update to GraphView (#1679) 2024-11-25 11:47:35 -05:00
Chenlei Hu
3708afaf21 [Electron] Add fp32 and fp64 to unet inference precision options (#1678) 2024-11-25 11:20:49 -05:00
Hayden
43c23e526c Change dialog to multi-window mode (#1672) 2024-11-24 21:36:30 -05:00
Chenlei Hu
a80eb84df1 1.4.10 (#1676) 2024-11-24 21:36:00 -05:00
Chenlei Hu
f89898b3d0 Add searchbox for extensions panel (#1675) 2024-11-24 21:33:43 -05:00
Chenlei Hu
af21142602 Use setting panel template (#1674)
* PanelTemplate

* Use panel template
2024-11-24 21:24:13 -05:00
Chenlei Hu
4b91860227 [Refactor] Extract SettingsPanel (#1673)
* [Refactor] Extract SettingsPanel

* nit
2024-11-24 20:48:35 -05:00
Chenlei Hu
e53bafbca6 [Electron] Add custom node migration placeholder (#1670) 2024-11-24 20:16:39 -05:00
Chenlei Hu
e01c8f06c7 [Electron] Show server launch args in server config panel (#1669)
* Move revertChanges

* Show launch args

* Explicit ServerConfigValue type

* nit

* nit

* Add tests
2024-11-24 18:14:05 -05:00
Chenlei Hu
c61ed4da37 Add server config modified message to prompt restart (#1668)
* Server config changed message

* Write to settings on unmount

* nit

* Highlight modified config

* Move modified logic to store

* Add jest test

* nit
2024-11-24 16:13:37 -05:00
Terry Jia
4a4d6d070a restore camera state (#1666) 2024-11-24 16:09:58 -05:00
Tristan Sommer
4bedd873a1 improved mouse brush adjustment, added zoom level indicator with reset, added invert button, bug fixes (#1664) 2024-11-24 11:32:13 -05:00
filtered
f8bd910e63 Fix terminal resizes incorrectly in flex parent (#1663)
Pushes siblings or self off-screen
2024-11-24 11:31:29 -05:00
42 changed files with 1745 additions and 1014 deletions

View File

@@ -3,9 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
import type { useWorkspaceStore } from '../src/stores/workspaceStore'
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
@@ -26,64 +23,41 @@ test.describe('Change Tracker', () => {
})
test('Can undo multiple operations', async ({ comfyPage }) => {
function isModified() {
return comfyPage.page.evaluate(async () => {
return !!(window['app'].extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
function getUndoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
function getRedoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(2)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(1)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(2)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(2)
})
})
@@ -174,4 +148,20 @@ test.describe('Change Tracker', () => {
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window['app'].graph.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.clickEmptySpace()
expect(await comfyPage.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.pan({ x: 10, y: 10 })
expect(await comfyPage.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -17,8 +17,11 @@ import {
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
import { SettingDialog } from './components/SettingDialog'
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyMenu {
public readonly sideToolbar: Locator
public readonly themeToggleButton: Locator
@@ -788,6 +791,26 @@ export class ComfyPage {
async moveMouseToEmptyArea() {
await this.page.mouse.move(10, 10)
}
async getUndoQueueSize() {
return this.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
async getRedoQueueSize() {
return this.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
async isCurrentWorkflowModified() {
return this.page.evaluate(() => {
return (window['app'].extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({

View File

@@ -465,6 +465,20 @@ test.describe('Menu', () => {
).toEqual(['workflow5.json'])
})
test('Can save temporary workflow with unmodified name', async ({
comfyPage
}) => {
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
await comfyPage.menu.topbar.saveWorkflow('Unsaved Workflow')
// Should not trigger the overwrite dialog
expect(
await comfyPage.page.locator('.comfy-modal-content:visible').count()
).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
})
test('Can overwrite other workflows with save as', async ({
comfyPage
}) => {

12
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.4.9",
"version": "1.4.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.4.9",
"version": "1.4.12",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@comfyorg/litegraph": "^0.8.37",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1923,9 +1923,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.35",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.35.tgz",
"integrity": "sha512-taxjPoNJLajZa3z3JSxwgArRIi5lYy3nlkmemup8bo0AtC7QpKOOE+xQ5wtSXcSMZZMxbsgQHp7FoBTeIUHngA==",
"version": "0.8.37",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.37.tgz",
"integrity": "sha512-HI3msNigdlW1pz5HMU7+5UpLX0TkWkLD8qOeVBFTwq4tGjsEfqWs6lowyjsWSJcjef/0fVvjsKV5hsTbeVWVkA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.4.9",
"version": "1.4.12",
"type": "module",
"scripts": {
"dev": "vite",
@@ -73,7 +73,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@comfyorg/litegraph": "^0.8.37",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative h-full w-full bg-black" ref="rootEl">
<div class="relative overflow-hidden h-full w-full bg-black" ref="rootEl">
<div class="p-terminal rounded-none h-full w-full p-2">
<div class="h-full terminal-host" ref="terminalEl"></div>
</div>

View File

@@ -1,7 +1,7 @@
<!-- A generalized form item for rendering in a form. -->
<template>
<div class="form-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<span class="text-muted" :class="props.labelClass">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
@@ -33,15 +33,11 @@ import CustomFormValue from '@/components/common/CustomFormValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
const formValue = defineModel<any>('formValue')
const props = withDefaults(
defineProps<{
item: FormItem
id: string | undefined
}>(),
{
id: undefined
}
)
const props = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }

View File

@@ -1,61 +1,51 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-model:visible="dialogStore.isVisible"
v-for="(item, index) in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
modal
closable
closeOnEscape
dismissableMask
:maximizable="maximizable"
:maximized="maximized"
@hide="dialogStore.closeDialog"
@maximize="onMaximize"
@unmaximize="onUnmaximize"
:aria-labelledby="headerId"
v-bind="item.dialogComponentProps"
:auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:aria-labelledby="item.key"
>
<template #header>
<component
v-if="dialogStore.headerComponent"
:is="dialogStore.headerComponent"
:id="headerId"
v-if="item.headerComponent"
:is="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="headerId">{{ dialogStore.title || ' ' }}</h3>
<h3 v-else :id="item.key">{{ item.title || ' ' }}</h3>
</template>
<component :is="dialogStore.component" v-bind="contentProps" />
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { ZIndex } from '@primeuix/utils/zindex'
import { usePrimeVue } from '@primevue/core'
import { useDialogStore } from '@/stores/dialogStore'
import Dialog from 'primevue/dialog'
const dialogStore = useDialogStore()
const maximizable = computed(
() => dialogStore.dialogComponentProps.maximizable ?? false
)
const maximized = ref(false)
const onMaximize = () => {
maximized.value = true
}
const primevue = usePrimeVue()
const onUnmaximize = () => {
maximized.value = false
}
const baseZIndex = computed(() => {
return primevue?.config?.zIndex?.modal ?? 1100
})
const contentProps = computed(() =>
maximizable.value
? {
...dialogStore.props,
maximized: maximized.value
}
: dialogStore.props
)
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
onMounted(() => {
const mask = document.createElement('div')
ZIndex.set('model', mask, baseZIndex.value)
})
</script>
<style>

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
@@ -50,6 +49,7 @@ import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { isElectron } from '@/utils/envUtil'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
const props = defineProps<{
error: ExecutionErrorWsMessage
@@ -65,7 +65,6 @@ const showReport = () => {
const showSendError = isElectron()
const toast = useToast()
const { copy, isSupported } = useClipboard()
onMounted(async () => {
try {
@@ -140,30 +139,9 @@ ${workflowText}
`
}
const { copyToClipboard } = useCopyToClipboard()
const copyReportToClipboard = async () => {
if (isSupported) {
try {
await copy(reportContent.value)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Report copied to clipboard',
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to copy report'
})
}
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Clipboard API not supported in your browser'
})
}
await copyToClipboard(reportContent.value)
}
const openNewGithubIssue = async () => {

View File

@@ -17,71 +17,46 @@
/>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4" />
<ScrollPanel class="settings-content flex-grow">
<Tabs :value="tabValue" :lazy="true">
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">
<div v-if="searchResults.length > 0">
<SettingGroup
v-for="(group, i) in searchResults"
:key="group.label"
:divider="i !== 0"
:group="group"
/>
</div>
<NoResultsPlaceholder
v-else
icon="pi pi-search"
:title="$t('noResultsFound')"
:message="$t('searchFailedMessage')"
/>
</TabPanel>
<TabPanel
v-for="category in categories"
:key="category.key"
:value="category.label"
>
<SettingGroup
v-for="(group, i) in sortedGroups(category)"
:key="group.label"
:divider="i !== 0"
:group="{
label: group.label,
settings: flattenTree<SettingParams>(group)
}"
/>
</TabPanel>
<TabPanel key="about" value="About">
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="extension" value="Extension">
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="server-config" value="Server-Config">
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</ScrollPanel>
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :settingGroups="searchResults" />
</PanelTemplate>
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label"
>
<template #header>
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
</template>
<SettingsPanel :settingGroups="sortedGroups(category)" />
</PanelTemplate>
<AboutPanel />
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
</template>
</Suspense>
</TabPanels>
</Tabs>
</div>
</template>
@@ -90,17 +65,16 @@ import { ref, computed, onMounted, watch, defineAsyncComponent } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import SettingsPanel from './setting/SettingsPanel.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import AboutPanel from './setting/AboutPanel.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import { flattenTree } from '@/utils/treeUtil'
import { isElectron } from '@/utils/envUtil'
const KeybindingPanel = defineAsyncComponent(
@@ -113,11 +87,6 @@ const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
interface ISettingGroup {
label: string
settings: SettingParams[]
}
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
@@ -158,8 +127,11 @@ const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
...settingCategories.value,
keybindingPanelNode,
...extensionPanelNodeList.value,
...serverConfigPanelNodeList.value,
@@ -178,10 +150,13 @@ onMounted(() => {
activeCategory.value = categories.value[0]
})
const sortedGroups = (category: SettingTreeNode) => {
return [...(category.children || [])].sort((a, b) =>
a.label.localeCompare(b.label)
)
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group)
}))
}
const searchQuery = ref<string>('')

View File

@@ -1,5 +1,5 @@
<template>
<div class="about-container">
<PanelTemplate value="About" class="about-container">
<h2 class="text-2xl font-bold mb-2">{{ $t('about') }}</h2>
<div class="space-y-2">
<a
@@ -26,10 +26,11 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
import PanelTemplate from './PanelTemplate.vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import Tag from 'primevue/tag'

View File

@@ -1,6 +1,35 @@
<template>
<div class="extension-panel">
<DataTable :value="extensionStore.extensions" stripedRows size="small">
<PanelTemplate value="Extension" class="extension-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchExtensions') + '...'"
/>
<Message v-if="hasChanges" severity="info" pt:text="w-full">
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button
:label="$t('reloadToApplyChanges')"
@click="applyChanges"
outlined
severity="danger"
/>
</div>
</Message>
</template>
<DataTable
:value="extensionStore.extensions"
stripedRows
size="small"
:filters="filters"
>
<Column field="name" :header="$t('extensionName')" sortable></Column>
<Column
:pt="{
@@ -15,28 +44,7 @@
</template>
</Column>
</DataTable>
<div class="mt-4">
<Message v-if="hasChanges" severity="info">
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
</Message>
<Button
:label="$t('reloadToApplyChanges')"
icon="pi pi-refresh"
@click="applyChanges"
:disabled="!hasChanges"
text
fluid
severity="danger"
/>
</div>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
@@ -48,6 +56,13 @@ import Column from 'primevue/column'
import ToggleSwitch from 'primevue/toggleswitch'
import Button from 'primevue/button'
import Message from 'primevue/message'
import { FilterMatchMode } from '@primevue/core/api'
import PanelTemplate from './PanelTemplate.vue'
import SearchBox from '@/components/common/SearchBox.vue'
const filters = ref({
global: { value: '', matchMode: FilterMatchMode.CONTAINS }
})
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()

View File

@@ -1,5 +1,12 @@
<template>
<div class="keybinding-panel">
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<DataTable
:value="commandsData"
v-model:selection="selectedCommandData"
@@ -11,12 +18,6 @@
header: 'px-0'
}"
>
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions invisible flex flex-row">
@@ -109,7 +110,7 @@
text
@click="resetKeybindings"
/>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
@@ -127,6 +128,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { useToast } from 'primevue/usetoast'

View File

@@ -0,0 +1,21 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex flex-col h-full w-full gap-2">
<slot name="header" />
<ScrollPanel class="flex-grow h-0 pr-2">
<slot />
</ScrollPanel>
<slot name="footer" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import ScrollPanel from 'primevue/scrollpanel'
const props = defineProps<{
value: string
class?: string
}>()
</script>

View File

@@ -1,37 +1,105 @@
<template>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
>
<Divider v-if="i > 0" />
<h3>{{ formatCamelCase(label) }}</h3>
<div v-for="item in items" :key="item.name" class="flex items-center mb-4">
<FormItem :item="item" v-model:formValue="item.value" />
<PanelTemplate value="Server-Config" class="server-config-panel">
<template #header>
<div class="flex flex-col gap-2">
<Message
v-if="modifiedConfigs.length > 0"
severity="info"
pt:text="w-full"
>
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button
:label="$t('serverConfig.revertChanges')"
@click="revertChanges"
outlined
/>
<Button
:label="$t('serverConfig.restart')"
@click="restartApp"
outlined
severity="danger"
/>
</div>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i-lucide:terminal class="text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
icon="pi pi-clipboard"
@click="copyCommandLineArgs"
severity="secondary"
text
/>
</div>
</Message>
</div>
</template>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
>
<Divider v-if="i > 0" />
<h3>{{ formatCamelCase(label) }}</h3>
<div
v-for="item in items"
:key="item.name"
class="flex items-center mb-4"
>
<FormItem
:item="item"
v-model:formValue="item.value"
:id="item.id"
:labelClass="{
'text-highlight': item.initialValue !== item.value
}"
/>
</div>
</div>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Message from 'primevue/message'
import Divider from 'primevue/divider'
import FormItem from '@/components/common/FormItem.vue'
import PanelTemplate from './PanelTemplate.vue'
import { formatCamelCase } from '@/utils/formatUtil'
import { useSettingStore } from '@/stores/settingStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { electronAPI } from '@/utils/envUtil'
import { useSettingStore } from '@/stores/settingStore'
import { watch } from 'vue'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const { serverConfigsByCategory, launchArgs, serverConfigValues } =
storeToRefs(serverConfigStore)
const {
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs
} = storeToRefs(serverConfigStore)
onMounted(() => {
serverConfigStore.loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
})
const revertChanges = () => {
serverConfigStore.revertChanges()
}
const restartApp = () => {
electronAPI().restartApp()
}
watch(launchArgs, (newVal) => {
settingStore.set('Comfy.Server.LaunchArgs', newVal)
@@ -40,4 +108,9 @@ watch(launchArgs, (newVal) => {
watch(serverConfigValues, (newVal) => {
settingStore.set('Comfy.Server.ServerConfigValues', newVal)
})
const { copyToClipboard } = useCopyToClipboard()
const copyCommandLineArgs = async () => {
await copyToClipboard(commandLineArgs.value)
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div v-if="props.settingGroups.length > 0">
<SettingGroup
v-for="(group, i) in props.settingGroups"
:key="group.label"
:divider="i !== 0"
:group="group"
/>
</div>
<NoResultsPlaceholder
v-else
icon="pi pi-search"
:title="$t('noResultsFound')"
:message="$t('searchFailedMessage')"
/>
</template>
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SettingGroup from './SettingGroup.vue'
import { ISettingGroup } from '@/types/settingTypes'
const props = defineProps<{
settingGroups: ISettingGroup[]
}>()
</script>

View File

@@ -57,6 +57,18 @@
</p>
</div>
</div>
<div class="flex items-center gap-3 p-2 rounded cursor-not-allowed">
<Checkbox disabled :binary="true" />
<div>
<label class="text-neutral-200 font-medium">
{{ $t('install.customNodes') }}
<Tag severity="secondary"> {{ $t('comingSoon') }}... </Tag>
</label>
<p class="text-sm text-neutral-400 my-1">
{{ $t('install.customNodesDescription') }}
</p>
</div>
</div>
</div>
</div>
@@ -70,6 +82,7 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { electronAPI } from '@/utils/envUtil'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'

View File

@@ -1,18 +1,6 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Popover ref="outputFilterPopup">
<OutputFilters />
</Popover>
<Button
icon="pi pi-filter"
text
severity="secondary"
@click="outputFilterPopup.toggle($event)"
v-tooltip="$t(`sideToolbar.queueTab.filter`)"
:class="{ 'text-yellow-500': anyFilter }"
/>
<Button
:icon="
imageFit === 'cover'
@@ -111,7 +99,6 @@ import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Popover from 'primevue/popover'
import ProgressSpinner from 'primevue/progressspinner'
import TaskItem from './queue/TaskItem.vue'
import ResultGallery from './queue/ResultGallery.vue'
@@ -124,9 +111,7 @@ import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { app } from '@/scripts/app'
const SETTING_FIT = 'Comfy.Queue.ImageFit'
const SETTING_FLAT = 'Comfy.Queue.ShowFlatList'
const SETTING_FILTER = 'Comfy.Queue.Filter'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
@@ -135,7 +120,7 @@ const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = computed<boolean>(() => settingStore.get(SETTING_FLAT))
const isExpanded = ref(false)
const visibleTasks = ref<TaskItemImpl[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
@@ -143,23 +128,7 @@ const galleryActiveIndex = ref(-1)
// 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(SETTING_FIT))
const hideCached = computed<boolean>(
() => settingStore.get(SETTING_FILTER)?.hideCached
)
const hideCanceled = computed<boolean>(
() => settingStore.get(SETTING_FILTER)?.hideCanceled
)
const anyFilter = computed(() => hideCanceled.value || hideCached.value)
watch(hideCached, () => {
updateVisibleTasks()
})
watch(hideCanceled, () => {
updateVisibleTasks()
})
const outputFilterPopup = ref(null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
@@ -180,31 +149,9 @@ const allGalleryItems = computed(() =>
})
)
const filterTasks = (tasks: TaskItemImpl[]) =>
tasks
.filter((t) => {
if (
hideCanceled.value &&
t.status?.messages?.at(-1)?.[0] === 'execution_interrupted'
) {
return false
}
if (
hideCached.value &&
t.flatOutputs?.length &&
t.flatOutputs.every((o) => o.cached)
) {
return false
}
return true
})
.slice(0, ITEMS_PER_PAGE)
const loadMoreItems = () => {
const currentLength = visibleTasks.value.length
const newTasks = filterTasks(allTasks.value).slice(
const newTasks = allTasks.value.slice(
currentLength,
currentLength + ITEMS_PER_PAGE
)
@@ -239,11 +186,11 @@ useResizeObserver(scrollContainer, () => {
})
const updateVisibleTasks = () => {
visibleTasks.value = filterTasks(allTasks.value)
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
}
const toggleExpanded = () => {
settingStore.set(SETTING_FLAT, !isExpanded.value)
isExpanded.value = !isExpanded.value
updateVisibleTasks()
}
@@ -344,10 +291,7 @@ const exitFolderView = () => {
}
const toggleImageFit = () => {
settingStore.set(
SETTING_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
settingStore.set(IMAGE_FIT, imageFit.value === 'cover' ? 'contain' : 'cover')
}
onMounted(() => {

View File

@@ -1,38 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
{{ $t('sideToolbar.queueTab.filters.hideCached') }}
<ToggleSwitch v-model="hideCached" />
</label>
<label class="flex items-center gap-2">
{{ $t('sideToolbar.queueTab.filters.hideCanceled') }}
<ToggleSwitch v-model="hideCanceled" />
</label>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ToggleSwitch from 'primevue/toggleswitch'
import { useSettingStore } from '@/stores/settingStore'
const SETTING_FILTER = 'Comfy.Queue.Filter'
const { t } = useI18n()
const settingStore = useSettingStore()
const filter = settingStore.get(SETTING_FILTER) ?? {}
const createCompute = (k: string) =>
computed({
get() {
return filter[k]
},
set(value) {
filter[k] = value
settingStore.set(SETTING_FILTER, filter)
}
})
const hideCached = createCompute('hideCached')
const hideCanceled = createCompute('hideCanceled')
</script>

View File

@@ -31,20 +31,16 @@
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory && node" class="node-name-tag">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Button
class="task-node-link"
:label="`${node.type} (#${node.id})`"
:label="`${node?.type} (#${node?.id})`"
link
size="small"
@click="app.goToNode(node?.id)"
/>
</Tag>
<Tag
:severity="taskTagSeverity(task.displayStatus)"
class="task-duration relative"
>
<i v-if="isCachedResult" class="pi pi-server task-cached-icon"></i>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
@@ -94,7 +90,6 @@ const node: ComfyNode | null =
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const isCachedResult = props.isFlatTask && coverResult?.cached
const emit = defineEmits<{
(
@@ -147,7 +142,7 @@ const taskStatusText = (status: TaskItemDisplayStatus) => {
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${isCachedResult ? ' cached' : ''}" style="font-weight: bold"></i>`
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
@@ -231,15 +226,4 @@ are floating on top of images. */
object-fit: cover;
object-position: center;
}
.task-cached-icon {
color: #fff;
}
:deep(.pi-check.cached) {
font-size: 12px;
position: absolute;
left: 16px;
top: 12px;
}
</style>

View File

@@ -198,25 +198,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'cover'
},
// Hidden setting used by the queue for if the results should be grouped by prompt
{
id: 'Comfy.Queue.ShowFlatList',
name: 'Queue show flat list',
type: 'hidden',
defaultValue: false
},
// Hidden setting used by the queue to filter certain results
{
id: 'Comfy.Queue.Filter',
name: 'Queue output filters',
type: 'hidden',
defaultValue: {
hideCanceled: false,
hideCached: false
},
versionAdded: '1.4.3'
},
{
id: 'Comfy.GroupSelectedNodes.Padding',
category: ['LiteGraph', 'Group', 'Padding'],
@@ -642,5 +623,18 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: {} as Record<string, string>,
versionAdded: '1.4.8'
},
{
id: 'Comfy.Queue.MaxHistoryItems',
name: 'Queue history size',
tooltip: 'The maximum number of tasks that show in the queue history.',
type: 'slider',
attrs: {
min: 16,
max: 256,
step: 16
},
defaultValue: 64,
versionAdded: '1.4.12'
}
]

View File

@@ -10,15 +10,17 @@ import {
VramManagement
} from '@/types/serverArgs'
export type ServerConfigValue = string | number | true | null | undefined
export interface ServerConfig<T> extends FormItem {
id: string
defaultValue: T
category?: string[]
// Override the default value getter with a custom function.
getValue?: (value: T) => Record<string, any>
getValue?: (value: T) => Record<string, ServerConfigValue>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<ServerConfigValue>[] = [
// We only need these settings in the web version. Desktop app manages them already.
{
id: 'listen',
@@ -43,21 +45,21 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'TLS Key File: Path to TLS key file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'tls-certfile',
name: 'TLS Certificate File: Path to TLS certificate file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'enable-cors-header',
name: 'Enable CORS header: Use "*" for all origins or specify domain',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'max-upload-size',
@@ -97,7 +99,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'CUDA device index to use',
category: ['CUDA'],
type: 'number',
defaultValue: undefined
defaultValue: null
},
{
id: 'cuda-malloc',
@@ -161,6 +163,8 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP64,
FloatingPointPrecision.FP32,
FloatingPointPrecision.FP16,
FloatingPointPrecision.BF16,
FloatingPointPrecision.FP8E4M3FN,
@@ -253,7 +257,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'DirectML device index',
category: ['Memory'],
type: 'number',
defaultValue: undefined
defaultValue: null
},
{
id: 'disable-ipex-optimize',
@@ -295,10 +299,10 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
},
{
id: 'cache-lru',
name: 'Use LRU caching with a maximum of N node results cached. (0 to disable).',
name: 'Use LRU caching with a maximum of N node results cached.',
category: ['Cache'],
type: 'number',
defaultValue: 0,
defaultValue: null,
tooltip: 'May use more RAM/VRAM.'
},
@@ -366,7 +370,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'Reserved VRAM (GB)',
category: ['Memory'],
type: 'number',
defaultValue: undefined,
defaultValue: null,
tooltip:
'Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reverved depending on your OS.'
},

View File

@@ -107,6 +107,14 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
// TODO(huchenlei): Add a confirmation dialog.
electronAPI.reinstall()
}
},
{
id: 'Comfy-Desktop.Restart',
label: 'Restart',
icon: 'pi pi-refresh',
function() {
electronAPI.restartApp()
}
}
],

View File

@@ -3,8 +3,7 @@ import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
@@ -185,6 +184,49 @@ class Load3d {
this.startAnimation()
}
getCameraState() {
const currentType = this.getCurrentCameraType()
return {
position: this.activeCamera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: currentType
}
}
setCameraState(state: {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
}
this.controls.update()
}
setUpDirection(
direction: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) {
@@ -358,26 +400,24 @@ class Load3d {
toggleCamera(cameraType?: 'perspective' | 'orthographic') {
const oldCamera = this.activeCamera
const position = this.activeCamera.position.clone()
const rotation = this.activeCamera.rotation.clone()
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const target = this.controls.target.clone()
if (!cameraType) {
this.activeCamera =
this.activeCamera === this.perspectiveCamera
oldCamera === this.perspectiveCamera
? this.orthographicCamera
: this.perspectiveCamera
} else {
const requestedCamera =
this.activeCamera =
cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera
if (this.activeCamera === requestedCamera) {
if (oldCamera === this.activeCamera) {
return
}
this.activeCamera = requestedCamera
}
this.activeCamera.position.copy(position)
@@ -394,6 +434,12 @@ class Load3d {
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.activeCamera === this.perspectiveCamera
? 'perspective'
: 'orthographic'
}
toggleGrid(showGrid: boolean) {
if (this.gridHelper) {
this.gridHelper.visible = showGrid
@@ -988,11 +1034,16 @@ function configureLoad3D(
bgColor: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
const onModelWidgetUpdate = async () => {
if (modelWidget.value) {
const filename = modelWidget.value as string
const createModelUpdateHandler = () => {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
const filename = value as string
const modelUrl = api.apiURL(
getResourceURL(...splitFilePath(filename), loadFolder)
)
@@ -1017,11 +1068,22 @@ function configureLoad3D(
if (postModelUpdateFunc) {
postModelUpdateFunc(load3d)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
load3d.setCameraState(cameraState)
} catch (error) {
console.warn('Failed to restore camera state:', error)
}
isFirstLoad = false
}
}
}
const onModelWidgetUpdate = createModelUpdateHandler()
if (modelWidget.value) {
onModelWidgetUpdate()
onModelWidgetUpdate(modelWidget.value)
}
modelWidget.callback = onModelWidgetUpdate
@@ -1079,6 +1141,8 @@ app.registerExtension({
LOAD_3D(node, inputName) {
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Load3D')
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-${load3dNode.length}`
container.classList.add('comfy-load-3d')
@@ -1212,6 +1276,21 @@ app.registerExtension({
(w: IWidget) => w.name === 'up_direction'
)
let cameraState
try {
const cameraInfo = node.properties['Camera Info']
if (
cameraInfo &&
typeof cameraInfo === 'string' &&
cameraInfo.trim() !== ''
) {
cameraState = JSON.parse(cameraInfo)
}
} catch (error) {
console.warn('Failed to parse camera state:', error)
cameraState = undefined
}
configureLoad3D(
load3d,
'input',
@@ -1222,13 +1301,16 @@ app.registerExtension({
material,
bgColor,
lightIntensity,
upDirection
upDirection,
cameraState
)
const w = node.widgets.find((w: IWidget) => w.name === 'width')
const h = node.widgets.find((w: IWidget) => w.name === 'height')
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = JSON.stringify(load3d.getCameraState())
const imageData = await load3d.captureScene(w.value, h.value)
const blob = await fetch(imageData).then((r) => r.blob())
@@ -1267,6 +1349,8 @@ app.registerExtension({
(wi) => wi.type == 'Load3DAnimation'
)
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-animation-${load3dNode.length}`
container.classList.add('comfy-load-3d-animation')
@@ -1466,6 +1550,21 @@ app.registerExtension({
}
}
let cameraState
try {
const cameraInfo = node.properties['Camera Info']
if (
cameraInfo &&
typeof cameraInfo === 'string' &&
cameraInfo.trim() !== ''
) {
cameraState = JSON.parse(cameraInfo)
}
} catch (error) {
console.warn('Failed to parse camera state:', error)
cameraState = undefined
}
configureLoad3D(
load3d,
'input',
@@ -1477,6 +1576,7 @@ app.registerExtension({
bgColor,
lightIntensity,
upDirection,
cameraState,
(load3d: Load3d) => {
const animationLoad3d = load3d as Load3dAnimation
const names = animationLoad3d.getAnimationNames()
@@ -1496,6 +1596,8 @@ app.registerExtension({
const h = node.widgets.find((w: IWidget) => w.name === 'height')
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = JSON.stringify(load3d.getCameraState())
load3d.toggleAnimation(false)
const imageData = await load3d.captureScene(w.value, h.value)

View File

@@ -275,11 +275,7 @@ var styles = `
justify-content: center;
align-items: center;
position: relative;
transition: background-color border 0.2s;
}
.maskEditor_toolPanelContainer:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
@@ -292,6 +288,15 @@ var styles = `
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
@@ -379,16 +384,56 @@ var styles = `
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton {
width: 53.3px;
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
@@ -657,6 +702,24 @@ var styles = `
margin-left: 15px;
}
.maskEditor_toolPanelZoomIndicator {
width: var(--sidebar-width);
height: var(--sidebar-width);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: var(--p-button-text-secondary-color);
position: absolute;
bottom: 0;
transition: background-color 0.2s;
}
#maskEditor_toolPanelDimensionsText {
font-size: 12px;
}
`
var styleSheet = document.createElement('style')
@@ -1229,6 +1292,8 @@ class PaintBucketTool {
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
}
private addPullTopics() {
@@ -1374,6 +1439,48 @@ class PaintBucketTool {
getTolerance(): number {
return this.tolerance
}
//invert mask
private invertMask() {
const imageData = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const data = imageData.data
// Find first non-transparent pixel to get mask color
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
// Process each pixel
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
// Invert alpha channel (0 becomes 255, 255 becomes 0)
data[i + 3] = 255 - alpha
// If this was originally transparent (now opaque), fill with mask color
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
this.ctx.putImageData(imageData, 0, 0)
this.messageBroker.publish('saveState')
}
}
class ColorSelectTool {
@@ -1809,10 +1916,15 @@ class BrushTool {
smoothingLastDrawTime!: Date
maskCtx: CanvasRenderingContext2D | null = null
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
initialPoint: Point | null = null
useDominantAxis: boolean = false
brushAdjustmentSpeed: number = 1.0
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
@@ -1823,6 +1935,13 @@ class BrushTool {
this.createListeners()
this.addPullTopics()
this.useDominantAxis = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
)
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
this.brushSettings = {
size: 10,
opacity: 100,
@@ -1860,7 +1979,7 @@ class BrushTool {
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.start_drawing(event)
this.startDrawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
@@ -1897,12 +2016,27 @@ class BrushTool {
)
}
private async start_drawing(event: PointerEvent) {
private async createBrushStrokeCanvas() {
if (this.brushStrokeCanvas !== null) {
return
}
const maskCanvas = await this.messageBroker.pull('maskCanvas')
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
this.brushStrokeCanvas = canvas
this.brushStrokeCtx = canvas.getContext('2d')!
}
private async startDrawing(event: PointerEvent) {
this.isDrawing = true
let compositionOp: CompositionOperation
let currentTool = await this.messageBroker.pull('currentTool')
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
await this.createBrushStrokeCanvas()
//set drawing mode
if (currentTool === Tools.Eraser || event.buttons == 2) {
@@ -1931,15 +2065,6 @@ class BrushTool {
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
let currentTool = await this.messageBroker.pull('currentTool')
/* move to draw
if (event instanceof PointerEvent && event.pointerType == 'pen') {
brush_size *= event.pressure
this.last_pressure = event.pressure
} else {
brush_size = this.brush_size //this is the problem with pen pressure
}
*/
if (diff > 20 && !this.isDrawing)
requestAnimationFrame(() => {
this.init_shape(CompositionOperation.SourceOver)
@@ -2051,29 +2176,62 @@ class BrushTool {
private async handleBrushAdjustment(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
const delta_x = coords_canvas.x - this.initialPoint!.x
const delta_y = coords_canvas.y - this.initialPoint!.y
// Adjust brush size (horizontal movement)
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
// New dominant axis logic
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
console.log(this.useDominantAxis)
if (this.useDominantAxis) {
// New setting flag
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0 // Configurable threshold
if (ratio > threshold) {
finalDeltaY = 0 // X is dominant
} else if (ratio < 1 / threshold) {
finalDeltaX = 0 // Y is dominant
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
// Rest of the function remains the same
const sizeDelta = cappedDeltaX / 40
const hardnessDelta = cappedDeltaY / 800
const newSize = Math.max(
1,
Math.min(100, this.brushSettings.size! + delta_x / 10)
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
)
// Adjust brush hardness (vertical movement)
const newHardness = Math.max(
0,
Math.min(1, this.brushSettings!.hardness - delta_y / 200)
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
return
}
//helper functions
@@ -2263,8 +2421,8 @@ class BrushTool {
}
private setBrushSmoothingPrecision(precision: number) {
console.log('precision', precision)
// this.brushSettings.smoothingPrecision = precision
//console.log('precision', precision)
this.smoothingPrecision = precision
}
}
@@ -2300,6 +2458,9 @@ class UIManager {
private mask_opacity: number = 0.7
private maskBlendMode: MaskBlendMode = MaskBlendMode.Black
private zoomTextHTML!: HTMLSpanElement
private dimensionsTextHTML!: HTMLSpanElement
constructor(rootElement: HTMLElement, maskEditor: MaskEditorDialog) {
this.rootElement = rootElement
this.maskEditor = maskEditor
@@ -2332,6 +2493,10 @@ class UIManager {
)
this.messageBroker.subscribe('updateCursor', () => this.updateCursor())
this.messageBroker.subscribe('setZoomText', (text: string) =>
this.setZoomText(text)
)
}
addPullTopics() {
@@ -2975,11 +3140,17 @@ class UIManager {
return separator
}
//----------------
private async createTopBar() {
const buttonAccentColor = this.darkMode
? 'maskEditor_topPanelButton_dark'
: 'maskEditor_topPanelButton_light'
const iconButtonAccentColor = this.darkMode
? 'maskEditor_topPanelIconButton_dark'
: 'maskEditor_topPanelIconButton_light'
var top_bar = document.createElement('div')
top_bar.id = 'maskEditor_topBar'
@@ -2997,9 +3168,9 @@ class UIManager {
var top_bar_undo_button = document.createElement('div')
top_bar_undo_button.id = 'maskEditor_topBarUndoButton'
top_bar_undo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_undo_button.classList.add(iconButtonAccentColor)
top_bar_undo_button.innerHTML =
'<svg viewBox="0 0 15 15" style="width: 36px;height: 36px;pointer-events: none;fill: var(--input-text);"><path d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"></path> </svg>'
'<svg viewBox="0 0 15 15"><path d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"></path> </svg>'
top_bar_undo_button.addEventListener('click', () => {
this.messageBroker.publish('undo')
@@ -3007,19 +3178,21 @@ class UIManager {
var top_bar_redo_button = document.createElement('div')
top_bar_redo_button.id = 'maskEditor_topBarRedoButton'
top_bar_redo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_redo_button.classList.add(iconButtonAccentColor)
top_bar_redo_button.innerHTML =
'<svg viewBox="0 0 15 15" style="width: 36px;height: 36px;pointer-events: none;fill: var(--input-text);"> <path class="cls-1" d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"/></svg>'
'<svg viewBox="0 0 15 15"> <path class="cls-1" d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"/></svg>'
top_bar_redo_button.addEventListener('click', () => {
this.messageBroker.publish('redo')
})
top_bar_shortcuts_container.appendChild(top_bar_undo_button)
top_bar_shortcuts_container.appendChild(top_bar_redo_button)
var top_bar_button_container = document.createElement('div')
top_bar_button_container.id = 'maskEditor_topBarButtonContainer'
var top_bar_invert_button = document.createElement('button')
top_bar_invert_button.id = 'maskEditor_topBarInvertButton'
top_bar_invert_button.classList.add(buttonAccentColor)
top_bar_invert_button.innerText = 'Invert'
top_bar_invert_button.addEventListener('click', () => {
this.messageBroker.publish('invert')
})
var top_bar_clear_button = document.createElement('button')
top_bar_clear_button.id = 'maskEditor_topBarClearButton'
@@ -3055,23 +3228,26 @@ class UIManager {
this.maskEditor.close()
})
top_bar_button_container.appendChild(top_bar_clear_button)
top_bar_button_container.appendChild(top_bar_save_button)
top_bar_button_container.appendChild(top_bar_cancel_button)
top_bar_shortcuts_container.appendChild(top_bar_undo_button)
top_bar_shortcuts_container.appendChild(top_bar_redo_button)
top_bar_shortcuts_container.appendChild(top_bar_invert_button)
top_bar_shortcuts_container.appendChild(top_bar_clear_button)
top_bar_shortcuts_container.appendChild(top_bar_save_button)
top_bar_shortcuts_container.appendChild(top_bar_cancel_button)
top_bar.appendChild(top_bar_title_container)
top_bar.appendChild(top_bar_shortcuts_container)
top_bar.appendChild(top_bar_button_container)
return top_bar
}
//----------------
private createToolPanel() {
var pen_tool_panel = document.createElement('div')
pen_tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = pen_tool_panel
var tool_panel = document.createElement('div')
tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = tool_panel
var toolPanelHoverAccent = this.darkMode
? 'maskEditor_toolPanelContainerDark'
: 'maskEditor_toolPanelContainerLight'
var toolElements: HTMLElement[] = []
@@ -3082,6 +3258,7 @@ class UIManager {
toolPanel_brushToolContainer.classList.add(
'maskEditor_toolPanelContainerSelected'
)
toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_brushToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M34,13.93c0,.47-.19.94-.55,1.31l-13.02,13.04c-.09.07-.18.15-.27.22-.07-1.39-1.21-2.48-2.61-2.49.07-.12.16-.24.27-.34l13.04-13.04c.72-.72,1.89-.72,2.6,0,.35.35.55.83.55,1.3Z"/>
@@ -3116,6 +3293,7 @@ class UIManager {
var toolPanel_eraserToolContainer = document.createElement('div')
toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer')
toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_eraserToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<g>
@@ -3155,6 +3333,7 @@ class UIManager {
toolPanel_paintBucketToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_paintBucketToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M33.4,21.76l-11.42,11.41-.04.05c-.61.61-1.6.61-2.21,0l-8.91-8.91c-.61-.61-.61-1.6,0-2.21l.04-.05.3-.29h22.24Z"/>
@@ -3198,6 +3377,7 @@ class UIManager {
toolPanel_colorSelectToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_colorSelectToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M30.29,13.72c-1.09-1.1-2.85-1.09-3.94,0l-2.88,2.88-.75-.75c-.2-.19-.51-.19-.71,0-.19.2-.19.51,0,.71l1.4,1.4-9.59,9.59c-.35.36-.54.82-.54,1.32,0,.14,0,.28.05.41-.05.04-.1.08-.15.13-.39.39-.39,1.01,0,1.4.38.39,1.01.39,1.4,0,.04-.04.08-.09.11-.13.14.04.3.06.45.06.5,0,.97-.19,1.32-.55l9.59-9.59,1.38,1.38c.1.09.22.14.35.14s.26-.05.35-.14c.2-.2.2-.52,0-.71l-.71-.72,2.88-2.89c1.08-1.08,1.08-2.85-.01-3.94ZM19.43,25.82h-2.46l7.15-7.15,1.23,1.23-5.92,5.92Z"/>
@@ -3230,17 +3410,35 @@ class UIManager {
toolPanel_colorSelectToolIndicator
)
pen_tool_panel.appendChild(toolPanel_brushToolContainer)
pen_tool_panel.appendChild(toolPanel_eraserToolContainer)
pen_tool_panel.appendChild(toolPanel_paintBucketToolContainer)
pen_tool_panel.appendChild(toolPanel_colorSelectToolContainer)
//zoom indicator
var toolPanel_zoomIndicator = document.createElement('div')
toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator')
toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent)
var pen_tool_panel_change_tool_button = document.createElement('button')
pen_tool_panel_change_tool_button.id =
'maskEditor_toolPanelChangeToolButton'
pen_tool_panel_change_tool_button.innerText = 'change to Eraser'
var toolPanel_zoomText = document.createElement('span')
toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText'
toolPanel_zoomText.innerText = '100%'
this.zoomTextHTML = toolPanel_zoomText
return pen_tool_panel
var toolPanel_DimensionsText = document.createElement('span')
toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText'
toolPanel_DimensionsText.innerText = ' '
this.dimensionsTextHTML = toolPanel_DimensionsText
toolPanel_zoomIndicator.appendChild(toolPanel_zoomText)
toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText)
toolPanel_zoomIndicator.addEventListener('click', () => {
this.messageBroker.publish('resetZoom')
})
tool_panel.appendChild(toolPanel_brushToolContainer)
tool_panel.appendChild(toolPanel_eraserToolContainer)
tool_panel.appendChild(toolPanel_paintBucketToolContainer)
tool_panel.appendChild(toolPanel_colorSelectToolContainer)
tool_panel.appendChild(toolPanel_zoomIndicator)
return tool_panel
}
private createPointerZone() {
@@ -3384,6 +3582,8 @@ class UIManager {
maskCanvas.width = this.image.width
maskCanvas.height = this.image.height
this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}`
await this.invalidateCanvas(this.image, mask_image)
this.messageBroker.publish('initZoomPan', [this.image, this.rootElement])
}
@@ -3638,6 +3838,14 @@ class UIManager {
this.updateBrushPreview()
this.setBrushPreviewGradientVisibility(false)
}
setZoomText(zoomText: string) {
this.zoomTextHTML.innerText = zoomText
}
setDimensionsText(dimensionsText: string) {
this.dimensionsTextHTML.innerText = dimensionsText
}
}
class ToolManager {
@@ -3814,6 +4022,7 @@ class PanAndZoomManager {
lastTouchPoint: Point = { x: 0, y: 0 }
zoom_ratio: number = 1
interpolatedZoomRatio: number = 1
pan_offset: Offset = { x: 0, y: 0 }
mouseDownPoint: Point | null = null
@@ -3821,8 +4030,11 @@ class PanAndZoomManager {
canvasContainer: HTMLElement | null = null
maskCanvas: HTMLCanvasElement | null = null
rootElement: HTMLElement | null = null
image: HTMLImageElement | null = null
imageRootWidth: number = 0
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
@@ -3878,6 +4090,11 @@ class PanAndZoomManager {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
@@ -4054,14 +4271,25 @@ class PanAndZoomManager {
const mouseX = cursorPoint.x - rect.left
const mouseY = cursorPoint.y - rect.top
console.log(oldZoom, newZoom)
// Calculate new pan position
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += mouseX - mouseX * scaleFactor
this.pan_offset.y += mouseY - mouseY * scaleFactor
console.log(this.imageRootWidth, this.imageRootHeight)
// Update pan and zoom immediately
await this.invalidatePanZoom()
const newImageWidth = maskCanvas.clientWidth
const zoomRatio = newImageWidth / this.imageRootWidth
this.interpolatedZoomRatio = zoomRatio
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
// Update cursor position with new pan values
this.updateCursorPosition(cursorPoint)
@@ -4071,51 +4299,125 @@ class PanAndZoomManager {
})
}
private async smoothResetView(duration: number = 500) {
// Store initial state
const startZoom = this.zoom_ratio
const startPan = { ...this.pan_offset }
// Panel dimensions
const sidePanelWidth = 220
const toolPanelWidth = 64
const topBarHeight = 44
// Calculate available space
const availableWidth =
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = this.rootElement!.clientHeight - topBarHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / this.image!.width
const zoomRatioHeight = availableHeight / this.image!.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
// Calculate final dimensions
const aspectRatio = this.image!.width / this.image!.height
let finalWidth = 0
let finalHeight = 0
// Calculate target pan position
const targetPan = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing out for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Calculate intermediate zoom and pan values
const currentZoom = startZoom + (targetZoom - startZoom) * eased
this.zoom_ratio = currentZoom
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
this.invalidatePanZoom()
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
this.messageBroker.publish(
'setZoomText',
`${Math.round(interpolatedZoomRatio * 100)}%`
)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
this.interpolatedZoomRatio = 1.0
}
async initializeCanvasPanZoom(
image: HTMLImageElement,
rootElement: HTMLElement
) {
// Get side panel width
let sidePanelWidth = 220
const toolPanelWidth = 64
let topBarHeight = 44
this.rootElement = rootElement
// Calculate available width accounting for both side panels
let availableWidth = rootElement.clientWidth - 2 * sidePanelWidth
let availableWidth =
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
// Initial dimensions
let drawWidth = image.width
let drawHeight = image.height
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
// First check if width needs scaling
if (drawWidth > availableWidth) {
drawWidth = availableWidth
drawHeight = (drawWidth / image.width) * image.height
}
let aspectRatio = image.width / image.height
// Then check if height needs scaling
if (drawHeight > availableHeight) {
drawHeight = availableHeight
drawWidth = (drawHeight / image.height) * image.width
let finalWidth = 0
let finalHeight = 0
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (this.image === null) {
this.image = image
}
this.zoom_ratio = drawWidth / image.width
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
// Center the canvas in the available space
const canvasX = sidePanelWidth + (availableWidth - drawWidth) / 2
const canvasY = (availableHeight - drawHeight) / 2
this.pan_offset.x = canvasX
this.pan_offset.y = canvasY
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
await this.invalidatePanZoom()
}
//probably move to PanZoomManager
async invalidatePanZoom() {
// Single validation check upfront
if (
@@ -4132,10 +4434,6 @@ class PanAndZoomManager {
const raw_width = this.image.width * this.zoom_ratio
const raw_height = this.image.height * this.zoom_ratio
// Adjust pan offset
this.pan_offset.x = Math.max(10 - raw_width, this.pan_offset.x)
this.pan_offset.y = Math.max(10 - raw_height, this.pan_offset.y)
// Get canvas container
this.canvasContainer ??=
await this.messageBroker?.pull('getCanvasContainer')
@@ -4238,6 +4536,9 @@ class MessageBroker {
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
}
/**
@@ -4425,12 +4726,38 @@ app.registerExtension({
settings: [
{
id: 'Comfy.MaskEditor.UseNewEditor',
category: ['Comfy', 'Masking'],
category: ['Mask Editor', 'NewEditor'],
name: 'Use new mask editor',
tooltip: 'Switch to the new mask editor interface',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.MaskEditor.BrushAdjustmentSpeed',
category: ['Mask Editor', 'BrushAdjustment', 'Sensitivity'],
name: 'Brush adjustment speed multiplier',
tooltip:
'Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes.',
experimental: true,
type: 'slider',
attrs: {
min: 0.1,
max: 2.0,
step: 0.1
},
defaultValue: 1.0,
versionAdded: '1.0.0'
},
{
id: 'Comfy.MaskEditor.UseDominantAxis',
category: ['Mask Editor', 'BrushAdjustment', 'UseDominantAxis'],
name: 'Lock brush adjustment to dominant axis',
tooltip:
'When enabled, brush adjustments will only affect size OR hardness based on which direction you move more',
type: 'boolean',
defaultValue: true,
experimental: true
}
],
init(app) {

View File

@@ -0,0 +1,37 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
const copyToClipboard = async (text: string) => {
if (isSupported) {
try {
await copy(text)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Copied to clipboard',
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to copy report'
})
}
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Clipboard API not supported in your browser'
})
}
}
return {
copyToClipboard
}
}

View File

@@ -1,453 +1,16 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
welcome: {
title: 'Welcome to ComfyUI',
getStarted: 'Get Started'
},
install: {
installLocation: 'Install Location',
migration: 'Migration',
desktopSettings: 'Desktop Settings',
chooseInstallationLocation: 'Choose Installation Location',
systemLocations: 'System Locations',
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
"ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
appPathLocationTooltip:
"ComfyUI's app asset directory. Stores the ComfyUI code and assets",
migrateFromExistingInstallation: 'Migrate from Existing Installation',
migrationSourcePathDescription:
'If you have an existing ComfyUI installation, we can copy/link your existing user files and models to the new installation.',
selectItemsToMigrate: 'Select Items to Migrate',
migrationOptional:
"Migration is optional. If you don't have an existing installation, you can skip this step.",
desktopAppSettings: 'Desktop App Settings',
desktopAppSettingsDescription:
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
}
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI'
}
},
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',
terminal: 'Terminal',
logs: 'Logs',
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert',
systemInfo: 'System Info',
devices: 'Devices',
about: 'About',
add: 'Add',
confirm: 'Confirm',
reset: 'Reset',
resetKeybindingsTooltip: 'Reset keybindings to default',
customizeFolder: 'Customize Folder',
icon: 'Icon',
color: 'Color',
bookmark: 'Bookmark',
folder: 'Folder',
star: 'Star',
heart: 'Heart',
file: 'File',
inbox: 'Inbox',
box: 'Box',
briefcase: 'Briefcase',
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
imageFailedToLoad: 'Image failed to load',
reconnecting: 'Reconnecting',
reconnected: 'Reconnected',
delete: 'Delete',
rename: 'Rename',
customize: 'Customize',
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
goToNode: 'Go to Node',
settings: 'Settings',
searchWorkflows: 'Search Workflows',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
searchModels: 'Search Models',
searchKeybindings: 'Search Keybindings',
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
noTasksFound: 'No Tasks Found',
noTasksFoundMessage: 'There are no tasks in the queue.',
newFolder: 'New Folder',
sideToolbar: {
themeToggle: 'Toggle Theme',
queue: 'Queue',
nodeLibrary: 'Node Library',
workflows: 'Workflows',
browseTemplates: 'Browse example templates',
openWorkflow: 'Open workflow in local file system',
newBlankWorkflow: 'Create a new blank workflow',
nodeLibraryTab: {
sortOrder: 'Sort Order'
},
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'
}
}
},
menu: {
hideMenu: 'Hide Menu',
showMenu: 'Show Menu',
batchCount: 'Batch Count',
batchCountTooltip:
'The number of times the workflow generation should be queued',
autoQueue: 'Auto Queue',
disabled: 'Disabled',
disabledTooltip: 'The workflow will not be automatically queued',
instant: 'Instant',
instantTooltip:
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow',
toggleBottomPanel: 'Toggle Bottom Panel'
},
templateWorkflows: {
title: 'Get Started with a Template',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
resetView: 'Reset View',
fitView: 'Fit View',
selectMode: 'Select Mode',
panMode: 'Pan Mode',
toggleLinkVisibility: 'Toggle Link Visibility'
},
electronFileDownload: {
inProgress: 'In Progress',
pause: 'Pause Download',
paused: 'Paused',
resume: 'Resume Download',
cancel: 'Cancel Download',
cancelled: 'Cancelled'
}
},
zh: {
firstTimeUIMessage:
'这是您第一次使用新界面。选择“Menu > Use New Menu > Disabled”以恢复旧界面。',
download: '下载',
loadAllFolders: '加载所有文件夹',
refresh: '刷新',
terminal: '终端',
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
insert: '插入',
systemInfo: '系统信息',
devices: '设备',
about: '关于',
add: '添加',
confirm: '确认',
reset: '重置',
resetKeybindingsTooltip: '重置键位',
customizeFolder: '定制文件夹',
icon: '图标',
color: '颜色',
bookmark: '书签',
folder: '文件夹',
star: '星星',
heart: '心',
file: '文件',
inbox: '收件箱',
box: '盒子',
briefcase: '公文包',
error: '错误',
loading: '加载中',
findIssues: '查找 Issue',
copyToClipboard: '复制到剪贴板',
openNewIssue: '开启新 Issue',
showReport: '显示报告',
imageFailedToLoad: '图像加载失败',
reconnecting: '重新连接中',
reconnected: '已重新连接',
delete: '删除',
rename: '重命名',
customize: '定制',
experimental: 'BETA',
deprecated: '弃用',
loadWorkflow: '加载工作流',
goToNode: '前往节点',
settings: '设置',
searchWorkflows: '搜索工作流',
searchSettings: '搜索设置',
searchNodes: '搜索节点',
searchModels: '搜索模型',
searchKeybindings: '搜索键位',
noResultsFound: '未找到结果',
searchFailedMessage:
'我们找不到与您的搜索匹配的任何设置。请尝试调整搜索条件。',
noContent: '(无内容)',
noTasksFound: '未找到任务',
noTasksFoundMessage: '队列中没有任务。',
newFolder: '新建文件夹',
sideToolbar: {
themeToggle: '主题切换',
queue: '队列',
nodeLibrary: '节点库',
workflows: '工作流',
browseTemplates: '浏览示例模板',
openWorkflow: '在本地文件系统中打开工作流',
newBlankWorkflow: '创建一个新空白工作流',
nodeLibraryTab: {
sortOrder: '排序顺序'
},
modelLibrary: '模型库',
queueTab: {
showFlatList: '平铺结果',
backToAllTasks: '返回',
containImagePreview: '填充图像预览',
coverImagePreview: '适应图像预览',
clearPendingTasks: '清除待处理任务'
}
},
menu: {
hideMenu: '隐藏菜单',
showMenu: '显示菜单',
batchCount: '批次数量',
batchCountTooltip: '工作流生成次数',
autoQueue: '自动执行',
disabled: '禁用',
disabledTooltip: '工作流将不会自动执行',
instant: '实时',
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行到队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
clipspace: '打开剪贴板',
resetView: '重置画布视图',
clear: '清空工作流',
toggleBottomPanel: '底部面板'
},
templateWorkflows: {
title: '从模板开始',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: '放大',
zoomOut: '缩小',
resetView: '重置视图',
fitView: '适应视图',
selectMode: '选择模式',
panMode: '平移模式',
toggleLinkVisibility: '切换链接可见性'
}
},
ru: {
download: 'Скачать',
refresh: 'Обновить',
loadAllFolders: 'Загрузить все папки',
terminal: 'Терминал',
videoFailedToLoad: 'Видео не удалось загрузить',
extensionName: 'Название расширения',
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',
insert: 'Вставить',
systemInfo: 'Информация о системе',
devices: 'Устройства',
about: 'О',
add: 'Добавить',
confirm: 'Подтвердить',
reset: 'Сбросить',
resetKeybindingsTooltip: 'Сбросить сочетания клавиш по умолчанию',
customizeFolder: 'Настроить папку',
icon: 'Иконка',
color: 'Цвет',
bookmark: 'Закладка',
folder: 'Папка',
star: 'Звёздочка',
heart: 'Сердце',
file: 'Файл',
inbox: 'Входящие',
box: 'Ящик',
briefcase: 'Чемодан',
error: 'Ошибка',
loading: 'Загрузка',
findIssues: 'Найти Issue',
copyToClipboard: 'Копировать в буфер обмена',
openNewIssue: 'Открыть новый Issue',
showReport: 'Показать отчёт',
imageFailedToLoad: 'Изображение не удалось загрузить',
reconnecting: 'Переподключение',
reconnected: 'Переподключено',
delete: 'Удалить',
rename: 'Переименовать',
customize: 'Настроить',
experimental: 'БЕТА',
deprecated: 'УСТАР',
loadWorkflow: 'Загрузить рабочий процесс',
goToNode: 'Перейти к узлу',
settings: 'Настройки',
searchWorkflows: 'Поиск рабочих процессов',
searchSettings: 'Поиск настроек',
searchNodes: 'Поиск узлов',
searchModels: 'Поиск моделей',
searchKeybindings: 'Поиск сочетаний клавиш',
noResultsFound: 'Ничего не найдено',
searchFailedMessage:
'Не удалось найти ни одной настройки, соответствующей вашему запросу. Попробуйте скорректировать поисковый запрос.',
noContent: '(Нет контента)',
noTasksFound: 'Задачи не найдены',
noTasksFoundMessage: 'В очереди нет задач.',
newFolder: 'Новая папка',
sideToolbar: {
themeToggle: 'Переключить тему',
queue: 'Очередь',
nodeLibrary: 'Библиотека узлов',
workflows: 'Рабочие процессы',
browseTemplates: 'Просмотреть примеры шаблонов',
openWorkflow: 'Открыть рабочий процесс в локальной файловой системе',
newBlankWorkflow: 'Создайте новый пустой рабочий процесс',
nodeLibraryTab: {
sortOrder: 'Порядок сортировки'
},
modelLibrary: 'Библиотека моделей',
queueTab: {
showFlatList: 'Показать плоский список',
backToAllTasks: 'Вернуться ко всем задачам',
containImagePreview: 'Предпросмотр заливающего изображения',
coverImagePreview: 'Предпросмотр подходящего изображения',
clearPendingTasks: 'Очистить отложенные задачи'
}
},
menu: {
hideMenu: 'Скрыть меню',
showMenu: 'Показать меню',
batchCount: 'Количество пакетов',
batchCountTooltip:
'Количество раз, когда генерация рабочего процесса должна быть помещена в очередь',
autoQueue: 'Автоочередь',
disabled: 'Отключено',
disabledTooltip:
'Рабочий процесс не будет автоматически помещён в очередь',
instant: 'Мгновенно',
instantTooltip:
'Рабочий процесс будет помещён в очередь сразу же после завершения генерации',
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',
refresh: 'Обновить определения узлов',
clipspace: 'Открыть Clipspace',
resetView: 'Сбросить вид холста',
clear: 'Очистить рабочий процесс'
},
templateWorkflows: {
title: 'Начните работу с шаблона',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
resetView: 'Сбросить вид',
fitView: 'Подгонять под выделенные',
selectMode: 'Выбрать режим',
panMode: 'Режим панорамирования',
toggleLinkVisibility: 'Переключить видимость ссылок'
}
}
// TODO: Add more languages
}
import en from './locales/en'
import zh from './locales/zh'
import ru from './locales/ru'
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages
messages: {
en,
zh,
ru
}
})

212
src/locales/en.ts Normal file
View File

@@ -0,0 +1,212 @@
export default {
welcome: {
title: 'Welcome to ComfyUI',
getStarted: 'Get Started'
},
install: {
installLocation: 'Install Location',
migration: 'Migration',
desktopSettings: 'Desktop Settings',
chooseInstallationLocation: 'Choose Installation Location',
systemLocations: 'System Locations',
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
"ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
appPathLocationTooltip:
"ComfyUI's app asset directory. Stores the ComfyUI code and assets",
migrateFromExistingInstallation: 'Migrate from Existing Installation',
migrationSourcePathDescription:
'If you have an existing ComfyUI installation, we can copy/link your existing user files and models to the new installation.',
selectItemsToMigrate: 'Select Items to Migrate',
migrationOptional:
"Migration is optional. If you don't have an existing installation, you can skip this step.",
desktopAppSettings: 'Desktop App Settings',
desktopAppSettingsDescription:
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
},
customNodes: 'Custom Nodes',
customNodesDescription:
'Reference custom node files from existing ComfyUI installations and install their dependencies.'
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI'
}
},
serverConfig: {
modifiedConfigs:
'You have modified the following server configurations. Restart to apply changes.',
revertChanges: 'Revert Changes',
restart: 'Restart'
},
comingSoon: 'Coming Soon',
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',
terminal: 'Terminal',
logs: 'Logs',
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert',
systemInfo: 'System Info',
devices: 'Devices',
about: 'About',
add: 'Add',
confirm: 'Confirm',
reset: 'Reset',
resetKeybindingsTooltip: 'Reset keybindings to default',
customizeFolder: 'Customize Folder',
icon: 'Icon',
color: 'Color',
bookmark: 'Bookmark',
folder: 'Folder',
star: 'Star',
heart: 'Heart',
file: 'File',
inbox: 'Inbox',
box: 'Box',
briefcase: 'Briefcase',
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
imageFailedToLoad: 'Image failed to load',
reconnecting: 'Reconnecting',
reconnected: 'Reconnected',
delete: 'Delete',
rename: 'Rename',
customize: 'Customize',
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
goToNode: 'Go to Node',
settings: 'Settings',
searchWorkflows: 'Search Workflows',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
searchModels: 'Search Models',
searchKeybindings: 'Search Keybindings',
searchExtensions: 'Search Extensions',
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
noTasksFound: 'No Tasks Found',
noTasksFoundMessage: 'There are no tasks in the queue.',
newFolder: 'New Folder',
sideToolbar: {
themeToggle: 'Toggle Theme',
queue: 'Queue',
nodeLibrary: 'Node Library',
workflows: 'Workflows',
browseTemplates: 'Browse example templates',
openWorkflow: 'Open workflow in local file system',
newBlankWorkflow: 'Create a new blank workflow',
nodeLibraryTab: {
sortOrder: 'Sort Order'
},
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'
}
}
},
menu: {
hideMenu: 'Hide Menu',
showMenu: 'Show Menu',
batchCount: 'Batch Count',
batchCountTooltip:
'The number of times the workflow generation should be queued',
autoQueue: 'Auto Queue',
disabled: 'Disabled',
disabledTooltip: 'The workflow will not be automatically queued',
instant: 'Instant',
instantTooltip:
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow',
toggleBottomPanel: 'Toggle Bottom Panel'
},
templateWorkflows: {
title: 'Get Started with a Template',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
resetView: 'Reset View',
fitView: 'Fit View',
selectMode: 'Select Mode',
panMode: 'Pan Mode',
toggleLinkVisibility: 'Toggle Link Visibility'
},
electronFileDownload: {
inProgress: 'In Progress',
pause: 'Pause Download',
paused: 'Paused',
resume: 'Resume Download',
cancel: 'Cancel Download',
cancelled: 'Cancelled'
}
}

120
src/locales/ru.ts Normal file
View File

@@ -0,0 +1,120 @@
export default {
download: 'Скачать',
refresh: 'Обновить',
loadAllFolders: 'Загрузить все папки',
terminal: 'Терминал',
videoFailedToLoad: 'Видео не удалось загрузить',
extensionName: 'Название расширения',
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',
insert: 'Вставить',
systemInfo: 'Информация о системе',
devices: 'Устройства',
about: 'О',
add: 'Добавить',
confirm: 'Подтвердить',
reset: 'Сбросить',
resetKeybindingsTooltip: 'Сбросить сочетания клавиш по умолчанию',
customizeFolder: 'Настроить папку',
icon: 'Иконка',
color: 'Цвет',
bookmark: 'Закладка',
folder: 'Папка',
star: 'Звёздочка',
heart: 'Сердце',
file: 'Файл',
inbox: 'Входящие',
box: 'Ящик',
briefcase: 'Чемодан',
error: 'Ошибка',
loading: 'Загрузка',
findIssues: 'Найти Issue',
copyToClipboard: 'Копировать в буфер обмена',
openNewIssue: 'Открыть новый Issue',
showReport: 'Показать отчёт',
imageFailedToLoad: 'Изображение не удалось загрузить',
reconnecting: 'Переподключение',
reconnected: 'Переподключено',
delete: 'Удалить',
rename: 'Переименовать',
customize: 'Настроить',
experimental: 'БЕТА',
deprecated: 'УСТАР',
loadWorkflow: 'Загрузить рабочий процесс',
goToNode: 'Перейти к узлу',
settings: 'Настройки',
searchWorkflows: 'Поиск рабочих процессов',
searchSettings: 'Поиск настроек',
searchNodes: 'Поиск узлов',
searchModels: 'Поиск моделей',
searchKeybindings: 'Поиск сочетаний клавиш',
searchExtensions: 'Поиск расширений',
noResultsFound: 'Ничего не найдено',
searchFailedMessage:
'Не удалось найти ни одной настройки, соответствующей вашему запросу. Попробуйте скорректировать поисковый запрос.',
noContent: '(Нет контента)',
noTasksFound: 'Задачи не найдены',
noTasksFoundMessage: 'В очереди нет задач.',
newFolder: 'Новая папка',
sideToolbar: {
themeToggle: 'Переключить тему',
queue: 'Очередь',
nodeLibrary: 'Библиотека узлов',
workflows: 'Рабочие процессы',
browseTemplates: 'Просмотреть примеры шаблонов',
openWorkflow: 'Открыть рабочий процесс в локальной файловой системе',
newBlankWorkflow: 'Создайте новый пустой рабочий процесс',
nodeLibraryTab: {
sortOrder: 'Порядок сортировки'
},
modelLibrary: 'Библиотека моделей',
queueTab: {
showFlatList: 'Показать плоский список',
backToAllTasks: 'Вернуться ко всем задачам',
containImagePreview: 'Предпросмотр заливающего изображения',
coverImagePreview: 'Предпросмотр подходящего изображения',
clearPendingTasks: 'Очистить отложенные задачи'
}
},
menu: {
hideMenu: 'Скрыть меню',
showMenu: 'Показать меню',
batchCount: 'Количество пакетов',
batchCountTooltip:
'Количество раз, когда генерация рабочего процесса должна быть помещена в очередь',
autoQueue: 'Автоочередь',
disabled: 'Отключено',
disabledTooltip: 'Рабочий процесс не будет автоматически помещён в очередь',
instant: 'Мгновенно',
instantTooltip:
'Рабочий процесс будет помещён в очередь сразу же после завершения генерации',
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',
refresh: 'Обновить определения узлов',
clipspace: 'Открыть Clipspace',
resetView: 'Сбросить вид холста',
clear: 'Очистить рабочий процесс'
},
templateWorkflows: {
title: 'Начните работу с шаблона',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
resetView: 'Сбросить вид',
fitView: 'Подгонять под выделенные',
selectMode: 'Выбрать режим',
panMode: 'Режим панорамирования',
toggleLinkVisibility: 'Переключить видимость ссылок'
}
}

120
src/locales/zh.ts Normal file
View File

@@ -0,0 +1,120 @@
export default {
firstTimeUIMessage:
'这是您第一次使用新界面。选择“Menu > Use New Menu > Disabled”以恢复旧界面。',
download: '下载',
loadAllFolders: '加载所有文件夹',
refresh: '刷新',
terminal: '终端',
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
insert: '插入',
systemInfo: '系统信息',
devices: '设备',
about: '关于',
add: '添加',
confirm: '确认',
reset: '重置',
resetKeybindingsTooltip: '重置键位',
customizeFolder: '定制文件夹',
icon: '图标',
color: '颜色',
bookmark: '书签',
folder: '文件夹',
star: '星星',
heart: '心',
file: '文件',
inbox: '收件箱',
box: '盒子',
briefcase: '公文包',
error: '错误',
loading: '加载中',
findIssues: '查找 Issue',
copyToClipboard: '复制到剪贴板',
openNewIssue: '开启新 Issue',
showReport: '显示报告',
imageFailedToLoad: '图像加载失败',
reconnecting: '重新连接中',
reconnected: '已重新连接',
delete: '删除',
rename: '重命名',
customize: '定制',
experimental: 'BETA',
deprecated: '弃用',
loadWorkflow: '加载工作流',
goToNode: '前往节点',
settings: '设置',
searchWorkflows: '搜索工作流',
searchSettings: '搜索设置',
searchNodes: '搜索节点',
searchModels: '搜索模型',
searchKeybindings: '搜索键位',
searchExtensions: '搜索插件',
noResultsFound: '未找到结果',
searchFailedMessage:
'我们找不到与您的搜索匹配的任何设置。请尝试调整搜索条件。',
noContent: '(无内容)',
noTasksFound: '未找到任务',
noTasksFoundMessage: '队列中没有任务。',
newFolder: '新建文件夹',
sideToolbar: {
themeToggle: '主题切换',
queue: '队列',
nodeLibrary: '节点库',
workflows: '工作流',
browseTemplates: '浏览示例模板',
openWorkflow: '在本地文件系统中打开工作流',
newBlankWorkflow: '创建一个新空白工作流',
nodeLibraryTab: {
sortOrder: '排序顺序'
},
modelLibrary: '模型库',
queueTab: {
showFlatList: '平铺结果',
backToAllTasks: '返回',
containImagePreview: '填充图像预览',
coverImagePreview: '适应图像预览',
clearPendingTasks: '清除待处理任务'
}
},
menu: {
hideMenu: '隐藏菜单',
showMenu: '显示菜单',
batchCount: '批次数量',
batchCountTooltip: '工作流生成次数',
autoQueue: '自动执行',
disabled: '禁用',
disabledTooltip: '工作流将不会自动执行',
instant: '实时',
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行到队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
clipspace: '打开剪贴板',
resetView: '重置画布视图',
clear: '清空工作流',
toggleBottomPanel: '底部面板'
},
templateWorkflows: {
title: '从模板开始',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: '放大',
zoomOut: '缩小',
resetView: '重置视图',
fitView: '适应视图',
selectMode: '选择模式',
panMode: '平移模式',
toggleLinkVisibility: '切换链接可见性'
}
}

View File

@@ -388,8 +388,11 @@ export class ChangeTracker {
return false
}
// Reroutes (schema 0.4)
if (!_.isEqual(a.extra?.reroutes, b.extra?.reroutes)) return false
// Compare extra properties ignoring ds
if (
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
)
return false
// Compare other properties normally
for (const key of ['links', 'reroutes', 'groups']) {

View File

@@ -66,7 +66,7 @@ export const workflowService = {
const workflowStore = useWorkflowStore()
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow) {
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Overwrite existing file?',
message: `"${newPath}" already exists. Do you want to overwrite it?`,

View File

@@ -2,52 +2,126 @@
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
import { defineStore } from 'pinia'
import { ref, shallowRef, type Component, markRaw } from 'vue'
import { ref, type Component, markRaw } from 'vue'
interface DialogComponentProps {
maximizable?: boolean
onClose?: () => void
}
export const useDialogStore = defineStore('dialog', () => {
const isVisible = ref(false)
const title = ref('')
const headerComponent = shallowRef<Component | null>(null)
const component = shallowRef<Component | null>(null)
const props = ref<Record<string, any>>({})
const dialogComponentProps = ref<DialogComponentProps>({})
interface DialogInstance {
key: string
visible: boolean
title?: string
headerComponent?: Component
component: Component
contentProps: Record<string, any>
dialogComponentProps: Record<string, any>
}
function showDialog(options: {
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
function riseDialog(options: { key: string }) {
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index !== -1) {
const dialogs = dialogStack.value.splice(index, 1)
dialogStack.value.push(...dialogs)
}
}
function closeDialog(options?: { key: string }) {
if (!options) {
dialogStack.value.pop()
return
}
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index === -1) {
return
}
dialogStack.value.splice(index, 1)
}
function createDialog(options: {
key: string
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
}) {
isVisible.value = true
title.value = options.title ?? ''
headerComponent.value = options.headerComponent
? markRaw(options.headerComponent)
: null
component.value = markRaw(options.component)
props.value = options.props || {}
dialogComponentProps.value = options.dialogComponentProps || {}
const dialog = {
key: options.key,
visible: true,
title: options.title,
headerComponent: options.headerComponent
? markRaw(options.headerComponent)
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
dialogComponentProps: {
maximizable: false,
modal: true,
closable: true,
closeOnEscape: true,
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
onUnmaximize: () => {
dialog.dialogComponentProps.maximized = false
},
onAfterHide: () => {
options.dialogComponentProps?.onClose?.()
closeDialog(dialog)
},
pt: {
root: {
onMousedown: () => {
riseDialog(dialog)
}
}
}
}
}
dialogStack.value.push(dialog)
return dialog
}
function closeDialog() {
if (dialogComponentProps.value.onClose) {
dialogComponentProps.value.onClose()
function showDialog(options: {
key?: string
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
}) {
const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
if (dialog) {
dialog.visible = true
riseDialog(dialog)
} else {
dialog = createDialog({ ...options, key: dialogKey })
}
isVisible.value = false
return dialog
}
return {
isVisible,
title,
headerComponent,
component,
props,
dialogComponentProps,
dialogStack,
riseDialog,
showDialog,
closeDialog
}

View File

@@ -30,7 +30,6 @@ export class ResultItemImpl {
filename: string
subfolder: string
type: string
cached: boolean
nodeId: NodeId
// 'audio' | 'images' | ...
@@ -44,7 +43,6 @@ export class ResultItemImpl {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
this.type = obj.type ?? ''
this.cached = obj.cached
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
@@ -151,13 +149,6 @@ export class TaskItemImpl {
if (!this.outputs) {
return []
}
const cachedEntries = new Set(
this.status?.messages?.find((message) => {
return message[0] === 'execution_cached'
})?.[1].nodes
)
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map(
@@ -165,8 +156,7 @@ export class TaskItemImpl {
new ResultItemImpl({
...item,
nodeId,
mediaType,
cached: cachedEntries.has(nodeId)
mediaType
})
)
)

View File

@@ -1,18 +1,39 @@
import { ServerConfig } from '@/constants/serverConfig'
import { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type ServerConfigWithValue<T> = ServerConfig<T> & {
/**
* Current value.
*/
value: T
/**
* Initial value loaded from settings.
*/
initialValue: T
}
export const useServerConfigStore = defineStore('serverConfig', () => {
const serverConfigById = ref<Record<string, ServerConfigWithValue<any>>>({})
const serverConfigById = ref<
Record<string, ServerConfigWithValue<ServerConfigValue>>
>({})
const serverConfigs = computed(() => {
return Object.values(serverConfigById.value)
})
const modifiedConfigs = computed<ServerConfigWithValue<ServerConfigValue>[]>(
() => {
return serverConfigs.value.filter((config) => {
return config.initialValue !== config.value
})
}
)
const revertChanges = () => {
for (const config of modifiedConfigs.value) {
config.value = config.initialValue
}
}
const serverConfigsByCategory = computed<
Record<string, ServerConfigWithValue<any>[]>
Record<string, ServerConfigWithValue<ServerConfigValue>[]>
>(() => {
return serverConfigs.value.reduce(
(acc, config) => {
@@ -21,15 +42,17 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
acc[category].push(config)
return acc
},
{} as Record<string, ServerConfigWithValue<any>[]>
{} as Record<string, ServerConfigWithValue<ServerConfigValue>[]>
)
})
const serverConfigValues = computed<Record<string, any>>(() => {
const serverConfigValues = computed<Record<string, ServerConfigValue>>(() => {
return Object.fromEntries(
serverConfigs.value.map((config) => {
return [
config.id,
config.value === config.defaultValue || !config.value
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
? undefined
: config.value
]
@@ -37,10 +60,18 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
)
})
const launchArgs = computed<Record<string, string>>(() => {
return Object.assign(
const args: Record<
string,
Omit<ServerConfigValue, 'undefined' | 'null'>
> = Object.assign(
{},
...serverConfigs.value.map((config) => {
if (config.value === config.defaultValue || !config.value) {
// Filter out configs that have the default value or undefined | null value
if (
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
) {
return {}
}
return config.getValue
@@ -48,16 +79,36 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
: { [config.id]: config.value }
})
)
// Convert true to empty string
// Convert number to string
return Object.fromEntries(
Object.entries(args).map(([key, value]) => {
if (value === true) {
return [key, '']
}
return [key, value.toString()]
})
) as Record<string, string>
})
const commandLineArgs = computed<string>(() => {
return Object.entries(launchArgs.value)
.map(([key, value]) => [`--${key}`, value])
.flat()
.filter((arg: string) => arg !== '')
.join(' ')
})
function loadServerConfig(
configs: ServerConfig<any>[],
values: Record<string, any>
configs: ServerConfig<ServerConfigValue>[],
values: Record<string, ServerConfigValue>
) {
for (const config of configs) {
const value = values[config.id] ?? config.defaultValue
serverConfigById.value[config.id] = {
...config,
value: values[config.id] ?? config.defaultValue
value,
initialValue: value
}
}
}
@@ -65,9 +116,12 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
return {
serverConfigById,
serverConfigs,
modifiedConfigs,
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
revertChanges,
loadServerConfig
}
})

View File

@@ -454,11 +454,6 @@ const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zQueueFilter = z.object({
hideCached: z.boolean(),
hideCanceled: z.boolean()
})
const zSettings = z.record(z.any()).and(
z
.object({
@@ -511,8 +506,6 @@ const zSettings = z.record(z.any()).and(
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Queue.ShowFlatList': z.boolean(),
'Comfy.Queue.Filter': zQueueFilter.passthrough(),
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.Window.UnloadConfirmation': z.boolean(),

View File

@@ -40,6 +40,7 @@ export enum CudaMalloc {
export enum FloatingPointPrecision {
AUTO = 'auto',
FP64 = 'fp64',
FP32 = 'fp32',
FP16 = 'fp16',
BF16 = 'bf16',

View File

@@ -58,3 +58,8 @@ export interface FormItem {
attrs?: Record<string, any>
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
}
export interface ISettingGroup {
label: string
settings: SettingParams[]
}

View File

@@ -19,7 +19,10 @@ import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { api } from '@/scripts/api'
import { StatusWsMessageStatus } from '@/types/apiTypes'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import {
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from '@/i18n'
@@ -35,6 +38,8 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useModelStore } from '@/stores/modelStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
setupAutoQueueHandler()
@@ -92,6 +97,12 @@ watchEffect(() => {
}
})
watchEffect(() => {
useQueueStore().maxHistoryItems = settingStore.get(
'Comfy.Queue.MaxHistoryItems'
)
})
const init = () => {
settingStore.addSettings(app.ui.settings)
useKeybindingStore().loadCoreKeybindings()
@@ -151,6 +162,12 @@ const onGraphReady = () => {
// Load keybindings.
useKeybindingStore().loadUserKeybindings()
// Load server config
useServerConfigStore().loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
// Load model folders
useModelStore().loadModelFolders()

View File

@@ -177,7 +177,8 @@ export default {
},
textColor: {
muted: 'var(--p-text-muted-color)'
muted: 'var(--p-text-muted-color)',
highlight: 'var(--p-primary-color)'
}
}
},

View File

@@ -170,20 +170,116 @@ describe('useServerConfigStore', () => {
const configs: ServerConfig<any>[] = [
{ ...dummyFormItem, id: 'test.config1', defaultValue: 'default1' },
{ ...dummyFormItem, id: 'test.config2', defaultValue: 'default2' },
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' }
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' },
{ ...dummyFormItem, id: 'test.config4', defaultValue: null }
]
store.loadServerConfig(configs, {
'test.config1': undefined,
'test.config2': null,
'test.config3': ''
'test.config3': '',
'test.config4': 0
})
expect(Object.keys(store.launchArgs)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toEqual([
'test.config1',
'test.config2',
'test.config3'
expect(Object.keys(store.launchArgs)).toEqual([
'test.config3',
'test.config4'
])
expect(Object.values(store.launchArgs)).toEqual(['', '0'])
expect(store.serverConfigById['test.config3'].value).toBe('')
expect(store.serverConfigById['test.config4'].value).toBe(0)
expect(Object.values(store.serverConfigValues)).toEqual([
undefined,
undefined,
'',
0
])
})
it('should convert true to empty string in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 0
}
],
{
'test.config1': true
}
)
expect(store.launchArgs['test.config1']).toBe('')
expect(store.commandLineArgs).toBe('--test.config1')
})
it('should convert number to string in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 1
}
],
{
'test.config1': 123
}
)
expect(store.launchArgs['test.config1']).toBe('123')
expect(store.commandLineArgs).toBe('--test.config1 123')
})
it('should drop nullish values in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 1
}
],
{
'test.config1': null
}
)
expect(Object.keys(store.launchArgs)).toHaveLength(0)
})
it('should track modified configs', () => {
const configs = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'initial1'
})
// Initially no modified configs
expect(store.modifiedConfigs).toHaveLength(0)
// Modify config1's value after loading
store.serverConfigById['test.config1'].value = 'custom1'
// Now config1 should be in modified configs
expect(store.modifiedConfigs).toHaveLength(1)
expect(store.modifiedConfigs[0].id).toBe('test.config1')
expect(store.modifiedConfigs[0].value).toBe('custom1')
expect(store.modifiedConfigs[0].initialValue).toBe('initial1')
// Change config1 back to default
store.serverConfigById['test.config1'].value = 'initial1'
// Should go back to no modified configs
expect(store.modifiedConfigs).toHaveLength(0)
})
})

View File

@@ -10,6 +10,9 @@ const mockElectronAPI: Plugin = {
{
tag: 'script',
children: `window.electronAPI = {
restartApp: () => {
alert('restartApp')
},
sendReady: () => {},
onShowSelectDirectory: () => {},
onFirstTimeSetupComplete: () => {},