Compare commits

..

2 Commits

Author SHA1 Message Date
pythongosssss
02c13c403f Safety mechanism to prevent loading everything 2024-11-24 11:44:26 +00:00
pythongosssss
8254e3c9cf Fix number of items shown when loading more
Fix number of items shown on status update
2024-11-24 11:37:02 +00:00
70 changed files with 1103 additions and 2317 deletions

View File

@@ -3,6 +3,9 @@ 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(() => {
@@ -23,41 +26,64 @@ test.describe('Change Tracker', () => {
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
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)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
const node = (await comfyPage.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(2)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(1)
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(2)
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(2)
})
})
@@ -148,20 +174,4 @@ 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

@@ -1,125 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
const nodeDef = {
title: 'TestNodeAdvancedDoc'
}
test.describe('Documentation Sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
})
test('Sidebar registered', async ({ comfyPage }) => {
await expect(
comfyPage.page.locator('.documentation-tab-button')
).toBeVisible()
})
test('Parses help for basic node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
//Check that each independently parsed element exists
await expect(docPane).toContainText('Load Checkpoint')
await expect(docPane).toContainText('Loads a diffusion model')
await expect(docPane).toContainText('The name of the checkpoint')
await expect(docPane).toContainText('The VAE model used')
})
test('Responds to hovering over node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(321, 593)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).not.toBeVisible()
await expect(
comfyPage.page.locator('.sidebar-content-container>div>div:nth-child(4)')
).toBeFocused()
})
test('Updates when a new node is selected', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.click(557, 440)
await expect(docPane).not.toContainText('Load Checkpoint')
await expect(docPane).toContainText('CLIP Text Encode (Prompt)')
await expect(docPane).toContainText('The text to be encoded')
await expect(docPane).toContainText(
'A conditioning containing the embedded text'
)
})
test('Responds to a change in theme', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.menu.toggleTheme()
await expect(docPane).toHaveScreenshot(
'documentation-sidebar-light-theme.png'
)
})
})
test.describe('Advanced Description tests', () => {
test.beforeEach(async ({ comfyPage }) => {
//register test node and add to graph
await comfyPage.page.evaluate(async (node) => {
const app = window['app']
await app.registerNodeDef(node.name, node)
app.addNodeOnGraph(node)
}, advDocNode)
})
test('Description displays as raw html', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container>div')
await expect(docPane).toHaveJSProperty(
'innerHTML',
advDocNode.description[1]
)
})
test('selected function', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window['app']
const desc =
LiteGraph.registered_node_types['Test_AdvancedDescription'].nodeData
.description
desc[2].select = (element, name, value) => {
element.children[0].innerText = name + ' ' + value
}
})
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(307, 80)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).not.toBeVisible()
await expect(docPane).toContainText('int_input 0')
})
})
const advDocNode = {
display_name: 'Node With Advanced Description',
name: 'Test_AdvancedDescription',
input: {
required: {
int_input: [
'INT',
{ tooltip: "an input tooltip that won't be displayed in sidebar" }
]
}
},
output: ['INT'],
output_name: ['int_output'],
output_tooltips: ["An output tooltip that won't be displayed in the sidebar"],
output_is_list: false,
description: [
'A node with description in the advanced format',
`
A long form description that will be displayed in the sidebar.
<div doc_title="INT">Can include arbitrary html</div>
<div doc_title="int_input">or out of order widgets</div>
`,
{}
]
}

View File

@@ -17,11 +17,8 @@ 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
@@ -791,26 +788,6 @@ 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,20 +465,6 @@ 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
}) => {

20
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative overflow-hidden h-full w-full bg-black" ref="rootEl">
<div class="relative 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

@@ -13,7 +13,18 @@ const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
const terminalApi = electronAPI().Terminal
// TODO: use types from electron package
const terminalApi = electronAPI()['Terminal'] as {
onOutput(cb: (message: string) => void): () => void
resize(cols: number, rows: number): void
restore(): Promise<{
buffer: string[]
pos: { x: number; y: number }
size: { cols: number; rows: number }
}>
storePos(x: number, y: number): void
write(data: string): void
}
let offData: IDisposable
let offOutput: () => void

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-muted" :class="props.labelClass">
<span class="text-[var(--p-text-muted-color)]">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
@@ -33,11 +33,15 @@ import CustomFormValue from '@/components/common/CustomFormValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
const formValue = defineModel<any>('formValue')
const props = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
const props = withDefaults(
defineProps<{
item: FormItem
id: string | undefined
}>(),
{
id: undefined
}
)
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }

View File

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

View File

@@ -38,6 +38,7 @@
<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'
@@ -49,7 +50,6 @@ 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,6 +65,7 @@ const showReport = () => {
const showSendError = isElectron()
const toast = useToast()
const { copy, isSupported } = useClipboard()
onMounted(async () => {
try {
@@ -139,9 +140,30 @@ ${workflowText}
`
}
const { copyToClipboard } = useCopyToClipboard()
const copyReportToClipboard = async () => {
await copyToClipboard(reportContent.value)
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'
})
}
}
const openNewGithubIssue = async () => {

View File

@@ -17,46 +17,71 @@
/>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4" />
<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>
<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>
</div>
</template>
@@ -65,16 +90,17 @@ 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 { ISettingGroup, SettingParams } from '@/types/settingTypes'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SettingsPanel from './setting/SettingsPanel.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
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(
@@ -87,6 +113,11 @@ const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
interface ISettingGroup {
label: string
settings: SettingParams[]
}
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
@@ -127,11 +158,8 @@ const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const categories = computed<SettingTreeNode[]>(() => [
...settingCategories.value,
...(settingRoot.value.children || []),
keybindingPanelNode,
...extensionPanelNodeList.value,
...serverConfigPanelNodeList.value,
@@ -150,13 +178,10 @@ onMounted(() => {
activeCategory.value = categories.value[0]
})
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 sortedGroups = (category: SettingTreeNode) => {
return [...(category.children || [])].sort((a, b) =>
a.label.localeCompare(b.label)
)
}
const searchQuery = ref<string>('')

View File

@@ -1,5 +1,5 @@
<template>
<PanelTemplate value="About" class="about-container">
<div class="about-container">
<h2 class="text-2xl font-bold mb-2">{{ $t('about') }}</h2>
<div class="space-y-2">
<a
@@ -26,11 +26,10 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</PanelTemplate>
</div>
</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,35 +1,6 @@
<template>
<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"
>
<div class="extension-panel">
<DataTable :value="extensionStore.extensions" stripedRows size="small">
<Column field="name" :header="$t('extensionName')" sortable></Column>
<Column
:pt="{
@@ -44,7 +15,28 @@
</template>
</Column>
</DataTable>
</PanelTemplate>
<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>
</template>
<script setup lang="ts">
@@ -56,13 +48,6 @@ 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,12 +1,5 @@
<template>
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<div class="keybinding-panel">
<DataTable
:value="commandsData"
v-model:selection="selectedCommandData"
@@ -18,6 +11,12 @@
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">
@@ -110,7 +109,7 @@
text
@click="resetKeybindings"
/>
</PanelTemplate>
</div>
</template>
<script setup lang="ts">
@@ -128,7 +127,6 @@ 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

@@ -1,21 +0,0 @@
<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,105 +1,37 @@
<template>
<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
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" />
</div>
</PanelTemplate>
</div>
</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 { electronAPI } from '@/utils/envUtil'
import { useSettingStore } from '@/stores/settingStore'
import { watch } from 'vue'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
import { onMounted, watch } from 'vue'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const {
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs
} = storeToRefs(serverConfigStore)
const { serverConfigsByCategory, launchArgs, serverConfigValues } =
storeToRefs(serverConfigStore)
const revertChanges = () => {
serverConfigStore.revertChanges()
}
const restartApp = () => {
electronAPI().restartApp()
}
onMounted(() => {
serverConfigStore.loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
})
watch(launchArgs, (newVal) => {
settingStore.set('Comfy.Server.LaunchArgs', newVal)
@@ -108,9 +40,4 @@ 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

@@ -1,26 +0,0 @@
<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

@@ -10,23 +10,20 @@
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { nextTick, ref } from 'vue'
import { LiteGraph } from '@comfyorg/litegraph'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useHoveredItemStore } from '@/stores/graphStore'
import { useEventListener } from '@vueuse/core'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const hoveredItemStore = useHoveredItemStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = null)
const clearHovered = () => (hoveredItemStore.value = null)
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
@@ -46,36 +43,6 @@ const showTooltip = async (tooltip: string | null | undefined) => {
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
}
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return hideTooltip()
}
const item = hoveredItem.value
const nodeDef =
nodeDefStore.nodeDefsByName[item.node.type] ??
LiteGraph.registered_node_types[item.node.type]?.nodeData
if (item.type == 'Title') {
let description = nodeDef.description
if (Array.isArray(description)) {
description = description[0]
}
return showTooltip(description)
} else if (item.type == 'Input') {
showTooltip(nodeDef.input.getInput(item.inputName)?.tooltip)
} else if (item.type == 'Output') {
showTooltip(nodeDef?.output?.all?.[item.outputSlot]?.tooltip)
} else if (item.type == 'Widget') {
showTooltip(
item.widget.tooltip ??
(
nodeDef.input.optional?.[item.widget.name] ??
nodeDef.input.required?.[item.widget.name]
)?.tooltip
)
} else {
hideTooltip()
}
})
const onIdle = () => {
const { canvas } = comfyApp
@@ -83,15 +50,13 @@ const onIdle = () => {
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef =
nodeDefStore.nodeDefsByName[node.type] ??
LiteGraph.registered_node_types[node.type]?.nodeData
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
if (
ctor.title_mode !== LiteGraph.NO_TITLE &&
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
) {
hoveredItemStore.value = { node, type: 'Title' }
return showTooltip(nodeDef.description)
}
if (node.flags?.collapsed) return
@@ -104,7 +69,7 @@ const onIdle = () => {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
hoveredItemStore.value = { node, type: 'Input', inputName }
return showTooltip(nodeDef.input.getInput(inputName)?.tooltip)
}
const outputSlot = canvas.isOverNodeOutput(
@@ -114,18 +79,20 @@ const onIdle = () => {
[0, 0]
)
if (outputSlot !== -1) {
hoveredItemStore.value = { node, type: 'Output', outputSlot }
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
}
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
hoveredItemStore.value = { node, type: 'Widget', widget }
return showTooltip(
widget.tooltip ?? nodeDef.input.getInput(widget.name)?.tooltip
)
}
}
const onMouseMove = (e: MouseEvent) => {
clearHovered()
hideTooltip()
clearTimeout(idleTimeout)
if ((e.target as Node).nodeName !== 'CANVAS') return
@@ -133,7 +100,7 @@ const onMouseMove = (e: MouseEvent) => {
}
useEventListener(window, 'mousemove', onMouseMove)
useEventListener(window, 'click', clearHovered)
useEventListener(window, 'click', hideTooltip)
</script>
<style lang="css" scoped>

View File

@@ -16,7 +16,7 @@
v-model="installPath"
class="w-full"
:class="{ 'p-invalid': pathError }"
@update:modelValue="validatePath"
@change="validatePath"
/>
<InputIcon
class="pi pi-info-circle"

View File

@@ -16,7 +16,7 @@
placeholder="Select existing ComfyUI installation (optional)"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@update:modelValue="validateSource"
@change="validateSource"
/>
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
</div>
@@ -57,18 +57,6 @@
</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>
@@ -82,7 +70,6 @@
<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

@@ -74,11 +74,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
backgroundColor: litegraphColors.WIDGET_BGCOLOR
}"
>
{{
Array.isArray(nodeDef.description)
? nodeDef.description[0]
: nodeDef.description
}}
{{ nodeDef.description }}
</div>
</div>
</template>

View File

@@ -1,196 +0,0 @@
<template>
<div v-if="!hasAnyDoc()">Select a node to see documentation.</div>
<div v-else-if="rawDoc" ref="docElement" v-html="rawDoc"></div>
<div v-else ref="docElement">
<div class="doc-node">{{ title }}</div>
<div>{{ description }}</div>
<div v-if="inputs.length" class="doc-section">Inputs</div>
<div
v-if="inputs.length"
v-for="input in inputs"
tabindex="-1"
class="doc-item"
>
{{ input[0] }}
<div>{{ input[1] }}</div>
</div>
<div v-if="outputs.length" class="doc-section">Outputs</div>
<div
v-if="outputs.length"
v-for="output in outputs"
tabindex="-1"
class="doc-item"
>
{{ output[0] }}
<div>{{ output[1] }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, isReactive } from 'vue'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useHoveredItemStore } from '@/stores/graphStore'
const hoveredItemStore = useHoveredItemStore()
const canvasStore = useCanvasStore()
const docElement = ref(null)
let def
const rawDoc = ref(null)
const description = ref(null)
const title = ref(null)
const inputs = ref([])
const outputs = ref([])
function selectHelp(name: string, value?: string) {
if (!docElement.value || !name) {
return null
}
if (def.description[2]?.select) {
return def.description[2].select(docElement.value, name, value)
}
//attempt to navigate to name in help
function collapseUnlessMatch(items, t) {
var match = items.querySelector('[doc_title="' + t + '"]')
if (!match) {
for (let i of items.children) {
if (i.innerHTML.slice(0, t.length + 5).includes(t)) {
match = i
break
}
}
}
if (!match) {
return null
}
//For longer documentation items with fewer collapsable elements,
//scroll to make sure the entirety of the selected item is visible
match.scrollIntoView({ block: 'nearest' })
//The previous floating help implementation would try to scroll the window
//itself if the display was partiall offscreen. As the sidebar documentation
//does not pan with the canvas, this should no longer be needed
//window.scrollTo(0, 0)
for (let i of items.querySelectorAll('.doc_collapse')) {
if (i.contains(match)) {
setCollapse(i, false)
} else {
setCollapse(i, true)
}
}
return match
}
let target = collapseUnlessMatch(docElement.value, name)
if (target) {
target.focus()
if (value) {
collapseUnlessMatch(target, value)
}
}
}
function updateNode() {
//Grab the topmost node.
//current_node is topmost on screen and
//selectedItems is unordered
const node = app?.graph?._nodes[app?.graph?._nodes.length - 1]
if (!node) {
// Graph has no nodes
return
}
const nodeDef = LiteGraph.getNodeType(node.type).nodeData
if (def == nodeDef) {
return
}
def = nodeDef
title.value = def.display_name
if (Array.isArray(def.description)) {
rawDoc.value = def.description[1]
outputs.value = []
inputs.value = []
return
} else {
rawDoc.value = null
}
description.value = def.description
let input_temp = []
for (let k in def?.input?.required) {
if (def.input.required[k][1]?.tooltip) {
input_temp.push([k, def.input.required[k][1].tooltip])
}
}
for (let k in def?.optional?.required) {
if (def.input.optional[k][1]?.tooltip) {
input_temp.push([k, def.input.optional[k][1].tooltip])
}
}
inputs.value = input_temp
if (def.output_tooltips) {
const outputs_temp = []
const output_name = def.output_name || def.output
for (let i = 0; i < def.output_tooltips.length; i++) {
outputs_temp[i] = [output_name[i], def.output_tooltips[i]]
}
outputs.value = outputs_temp
} else {
outputs.value = []
}
}
function hasAnyDoc() {
return def?.description || inputs.value.length || outputs.value.length
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return
}
const item = hoveredItem.value
const nodeDef = LiteGraph.getNodeType(item.node.type).nodeData
if (nodeDef != def) {
return
}
if (item.type == 'DESCRIPTION') {
return
} else if (item.type == 'Input') {
selectHelp(item.inputName)
hoveredItem.value = null
} else if (item.type == 'Output') {
selectHelp(nodeDef?.output?.all?.[item.outputSlot]?.name)
hoveredItem.value = null
} else if (item.type == 'Widget') {
selectHelp(item.widget.name, item.widget.value)
hoveredItem.value = null
}
})
if (isReactive(canvasStore?.canvas)) {
watch(() => canvasStore.canvas?.current_node, updateNode)
} else {
let interval = setInterval(updateNode, 300)
onBeforeUnmount(() => clearInterval(this.interval))
}
updateNode()
</script>
<style scoped>
.doc-node {
font-size: 1.5em;
}
.doc-section {
background-color: var(--comfy-menu-bg);
}
.doc-item div {
margin-inline-start: 1vw;
}
@keyframes selectAnimation {
0% {
background-color: #5555;
}
80% {
background-color: #5555;
}
100% {
background-color: #0000;
}
}
.doc-item:focus {
animation: selectAnimation 2s;
}
</style>

View File

@@ -1,6 +1,18 @@
<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'
@@ -99,6 +111,7 @@ 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'
@@ -111,7 +124,9 @@ import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { app } from '@/scripts/app'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const SETTING_FIT = 'Comfy.Queue.ImageFit'
const SETTING_FLAT = 'Comfy.Queue.ShowFlatList'
const SETTING_FILTER = 'Comfy.Queue.Filter'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
@@ -120,7 +135,7 @@ const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const isExpanded = computed<boolean>(() => settingStore.get(SETTING_FLAT))
const visibleTasks = ref<TaskItemImpl[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
@@ -128,10 +143,27 @@ 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(IMAGE_FIT))
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 ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
const MAX_LOAD_ITERATIONS = 10
const allTasks = computed(() =>
isInFolderView.value
@@ -149,21 +181,48 @@ const allGalleryItems = computed(() =>
})
)
const loadMoreItems = () => {
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
})
const loadMoreItems = (iteration: number) => {
const currentLength = visibleTasks.value.length
const newTasks = allTasks.value.slice(
const newTasks = filterTasks(allTasks.value).slice(
currentLength,
currentLength + ITEMS_PER_PAGE
)
visibleTasks.value.push(...newTasks)
// If we've added some items, check if we need to add more
// Prevent loading everything at once in case of render update issues
if (newTasks.length && iteration < MAX_LOAD_ITERATIONS) {
nextTick(() => {
checkAndLoadMore(iteration + 1)
})
}
}
const checkAndLoadMore = () => {
const checkAndLoadMore = (iteration: number) => {
if (!scrollContainer.value) return
const { scrollHeight, scrollTop, clientHeight } = scrollContainer.value
if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD) {
loadMoreItems()
loadMoreItems(iteration)
}
}
@@ -171,7 +230,7 @@ useInfiniteScroll(
scrollContainer,
() => {
if (visibleTasks.value.length < allTasks.value.length) {
loadMoreItems()
loadMoreItems(0)
}
},
{ distance: SCROLL_THRESHOLD }
@@ -181,16 +240,20 @@ useInfiniteScroll(
// This is necessary as the sidebar tab can change size when user drags the splitter.
useResizeObserver(scrollContainer, () => {
nextTick(() => {
checkAndLoadMore()
checkAndLoadMore(0)
})
})
const updateVisibleTasks = () => {
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
visibleTasks.value = filterTasks(allTasks.value).slice(0, ITEMS_PER_PAGE)
nextTick(() => {
checkAndLoadMore(0)
})
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
settingStore.set(SETTING_FLAT, !isExpanded.value)
updateVisibleTasks()
}
@@ -291,7 +354,10 @@ const exitFolderView = () => {
}
const toggleImageFit = () => {
settingStore.set(IMAGE_FIT, imageFit.value === 'cover' ? 'contain' : 'cover')
settingStore.set(
SETTING_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
}
onMounted(() => {

View File

@@ -95,18 +95,11 @@
renderTreeNode(workflowsTree, WorkflowTreeType.Browse).children
"
v-model:expandedKeys="expandedKeys"
v-if="workflowStore.persistedWorkflows.length > 0"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
<NoResultsPlaceholder
v-else
icon="pi pi-folder"
:title="$t('empty')"
:message="$t('noWorkflowsFound')"
/>
</div>
</div>
<div class="comfyui-workflows-search-panel" v-else>
@@ -127,7 +120,6 @@
<script setup lang="ts">
import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'

View File

@@ -1,10 +1,10 @@
<template>
<div class="mx-6 mb-4" v-if="inProgressDownloads.length > 0">
<div class="mx-6 mb-4" v-if="downloads.length > 0">
<div class="text-lg my-4">
{{ $t('electronFileDownload.inProgress') }}
</div>
<template v-for="download in inProgressDownloads" :key="download.url">
<template v-for="download in downloads" :key="download.url">
<DownloadItem :download="download" />
</template>
</div>
@@ -16,5 +16,5 @@ import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { storeToRefs } from 'pinia'
const electronDownloadStore = useElectronDownloadStore()
const { inProgressDownloads } = storeToRefs(electronDownloadStore)
const { downloads } = storeToRefs(electronDownloadStore)
</script>

View File

@@ -0,0 +1,38 @@
<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,16 +31,20 @@
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Tag v-if="isFlatTask && task.isHistory && node" 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)">
<Tag
:severity="taskTagSeverity(task.displayStatus)"
class="task-duration relative"
>
<i v-if="isCachedResult" class="pi pi-server task-cached-icon"></i>
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
@@ -90,6 +94,7 @@ const node: ComfyNode | null =
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const isCachedResult = props.isFlatTask && coverResult?.cached
const emit = defineEmits<{
(
@@ -142,7 +147,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" style="font-weight: bold"></i>'
return `<i class="pi pi-check${isCachedResult ? ' cached' : ''}" style="font-weight: bold"></i>`
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
@@ -226,4 +231,15 @@ 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,6 +198,25 @@ 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'],
@@ -623,18 +642,5 @@ 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,17 +10,15 @@ 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, ServerConfigValue>
getValue?: (value: T) => Record<string, any>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<ServerConfigValue>[] = [
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
// We only need these settings in the web version. Desktop app manages them already.
{
id: 'listen',
@@ -45,21 +43,21 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'TLS Key File: Path to TLS key file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: ''
defaultValue: undefined
},
{
id: 'tls-certfile',
name: 'TLS Certificate File: Path to TLS certificate file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: ''
defaultValue: undefined
},
{
id: 'enable-cors-header',
name: 'Enable CORS header: Use "*" for all origins or specify domain',
category: ['Network'],
type: 'text',
defaultValue: ''
defaultValue: undefined
},
{
id: 'max-upload-size',
@@ -99,7 +97,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'CUDA device index to use',
category: ['CUDA'],
type: 'number',
defaultValue: null
defaultValue: undefined
},
{
id: 'cuda-malloc',
@@ -163,8 +161,6 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP64,
FloatingPointPrecision.FP32,
FloatingPointPrecision.FP16,
FloatingPointPrecision.BF16,
FloatingPointPrecision.FP8E4M3FN,
@@ -257,7 +253,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'DirectML device index',
category: ['Memory'],
type: 'number',
defaultValue: null
defaultValue: undefined
},
{
id: 'disable-ipex-optimize',
@@ -299,10 +295,10 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
},
{
id: 'cache-lru',
name: 'Use LRU caching with a maximum of N node results cached.',
name: 'Use LRU caching with a maximum of N node results cached. (0 to disable).',
category: ['Cache'],
type: 'number',
defaultValue: null,
defaultValue: 0,
tooltip: 'May use more RAM/VRAM.'
},
@@ -370,7 +366,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'Reserved VRAM (GB)',
category: ['Memory'],
type: 'number',
defaultValue: null,
defaultValue: undefined,
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

@@ -17,11 +17,9 @@ app.registerExtension({
if (node.widgets) {
// Locate dynamic prompt text widgets
// Include any widgets with dynamicPrompts set to true, and customtext
// @ts-expect-error dynamicPrompts is not typed
const widgets = node.widgets.filter((n) => n.dynamicPrompts)
for (const widget of widgets) {
// Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
// @ts-expect-error hacky override
widget.serializeValue = (workflowNode, widgetIndex) => {
let prompt = stripComments(widget.value)
while (

View File

@@ -107,14 +107,6 @@ 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

@@ -1,10 +1,10 @@
// @ts-strict-ignore
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTF } 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,49 +185,6 @@ 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'
) {
@@ -401,24 +358,26 @@ class Load3d {
toggleCamera(cameraType?: 'perspective' | 'orthographic') {
const oldCamera = this.activeCamera
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const position = this.activeCamera.position.clone()
const rotation = this.activeCamera.rotation.clone()
const target = this.controls.target.clone()
if (!cameraType) {
this.activeCamera =
oldCamera === this.perspectiveCamera
this.activeCamera === this.perspectiveCamera
? this.orthographicCamera
: this.perspectiveCamera
} else {
this.activeCamera =
const requestedCamera =
cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera
if (oldCamera === this.activeCamera) {
if (this.activeCamera === requestedCamera) {
return
}
this.activeCamera = requestedCamera
}
this.activeCamera.position.copy(position)
@@ -435,12 +394,6 @@ class Load3d {
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.activeCamera === this.perspectiveCamera
? 'perspective'
: 'orthographic'
}
toggleGrid(showGrid: boolean) {
if (this.gridHelper) {
this.gridHelper.visible = showGrid
@@ -1035,16 +988,11 @@ function configureLoad3D(
bgColor: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
const createModelUpdateHandler = () => {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
const filename = value as string
const onModelWidgetUpdate = async () => {
if (modelWidget.value) {
const filename = modelWidget.value as string
const modelUrl = api.apiURL(
getResourceURL(...splitFilePath(filename), loadFolder)
)
@@ -1069,22 +1017,11 @@ 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(modelWidget.value)
onModelWidgetUpdate()
}
modelWidget.callback = onModelWidgetUpdate
@@ -1142,8 +1079,6 @@ 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')
@@ -1277,21 +1212,6 @@ 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',
@@ -1302,17 +1222,13 @@ app.registerExtension({
material,
bgColor,
lightIntensity,
upDirection,
cameraState
upDirection
)
const w = node.widgets.find((w: IWidget) => w.name === 'width')
const h = node.widgets.find((w: IWidget) => w.name === 'height')
// @ts-expect-error hacky override
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())
@@ -1351,8 +1267,6 @@ 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')
@@ -1552,21 +1466,6 @@ 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',
@@ -1578,7 +1477,6 @@ app.registerExtension({
bgColor,
lightIntensity,
upDirection,
cameraState,
(load3d: Load3d) => {
const animationLoad3d = load3d as Load3dAnimation
const names = animationLoad3d.getAnimationNames()
@@ -1597,10 +1495,7 @@ app.registerExtension({
const w = node.widgets.find((w: IWidget) => w.name === 'width')
const h = node.widgets.find((w: IWidget) => w.name === 'height')
// @ts-expect-error hacky override
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,7 +275,11 @@ var styles = `
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
transition: background-color border 0.2s;
}
.maskEditor_toolPanelContainer:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
@@ -288,15 +292,6 @@ 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%;
@@ -384,56 +379,16 @@ var styles = `
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
.maskEditor_topPanelIconButton {
width: 53.3px;
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 {
@@ -702,24 +657,6 @@ 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')
@@ -1292,8 +1229,6 @@ class PaintBucketTool {
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
}
private addPullTopics() {
@@ -1439,48 +1374,6 @@ 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 {
@@ -1916,15 +1809,10 @@ 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
@@ -1935,13 +1823,6 @@ 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,
@@ -1979,7 +1860,7 @@ class BrushTool {
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.startDrawing(event)
this.start_drawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
@@ -2016,27 +1897,12 @@ class BrushTool {
)
}
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) {
private async start_drawing(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) {
@@ -2065,6 +1931,15 @@ 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)
@@ -2176,62 +2051,29 @@ 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
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
// Adjust brush size (horizontal movement)
const newSize = Math.max(
1,
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
Math.min(100, this.brushSettings.size! + delta_x / 10)
)
// Adjust brush hardness (vertical movement)
const newHardness = Math.max(
0,
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
Math.min(1, this.brushSettings!.hardness - delta_y / 200)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
return
}
//helper functions
@@ -2421,8 +2263,8 @@ class BrushTool {
}
private setBrushSmoothingPrecision(precision: number) {
//console.log('precision', precision)
this.smoothingPrecision = precision
console.log('precision', precision)
// this.brushSettings.smoothingPrecision = precision
}
}
@@ -2458,9 +2300,6 @@ 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
@@ -2493,10 +2332,6 @@ class UIManager {
)
this.messageBroker.subscribe('updateCursor', () => this.updateCursor())
this.messageBroker.subscribe('setZoomText', (text: string) =>
this.setZoomText(text)
)
}
addPullTopics() {
@@ -3140,17 +2975,11 @@ 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'
@@ -3168,9 +2997,9 @@ class UIManager {
var top_bar_undo_button = document.createElement('div')
top_bar_undo_button.id = 'maskEditor_topBarUndoButton'
top_bar_undo_button.classList.add(iconButtonAccentColor)
top_bar_undo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_undo_button.innerHTML =
'<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>'
'<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>'
top_bar_undo_button.addEventListener('click', () => {
this.messageBroker.publish('undo')
@@ -3178,21 +3007,19 @@ class UIManager {
var top_bar_redo_button = document.createElement('div')
top_bar_redo_button.id = 'maskEditor_topBarRedoButton'
top_bar_redo_button.classList.add(iconButtonAccentColor)
top_bar_redo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_redo_button.innerHTML =
'<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>'
'<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>'
top_bar_redo_button.addEventListener('click', () => {
this.messageBroker.publish('redo')
})
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')
})
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_clear_button = document.createElement('button')
top_bar_clear_button.id = 'maskEditor_topBarClearButton'
@@ -3228,26 +3055,23 @@ class UIManager {
this.maskEditor.close()
})
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_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.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 tool_panel = document.createElement('div')
tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = tool_panel
var toolPanelHoverAccent = this.darkMode
? 'maskEditor_toolPanelContainerDark'
: 'maskEditor_toolPanelContainerLight'
var pen_tool_panel = document.createElement('div')
pen_tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = pen_tool_panel
var toolElements: HTMLElement[] = []
@@ -3258,7 +3082,6 @@ 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"/>
@@ -3293,7 +3116,6 @@ 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>
@@ -3333,7 +3155,6 @@ 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"/>
@@ -3377,7 +3198,6 @@ 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"/>
@@ -3410,35 +3230,17 @@ class UIManager {
toolPanel_colorSelectToolIndicator
)
//zoom indicator
var toolPanel_zoomIndicator = document.createElement('div')
toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator')
toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent)
pen_tool_panel.appendChild(toolPanel_brushToolContainer)
pen_tool_panel.appendChild(toolPanel_eraserToolContainer)
pen_tool_panel.appendChild(toolPanel_paintBucketToolContainer)
pen_tool_panel.appendChild(toolPanel_colorSelectToolContainer)
var toolPanel_zoomText = document.createElement('span')
toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText'
toolPanel_zoomText.innerText = '100%'
this.zoomTextHTML = toolPanel_zoomText
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_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
return pen_tool_panel
}
private createPointerZone() {
@@ -3582,8 +3384,6 @@ 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])
}
@@ -3838,14 +3638,6 @@ class UIManager {
this.updateBrushPreview()
this.setBrushPreviewGradientVisibility(false)
}
setZoomText(zoomText: string) {
this.zoomTextHTML.innerText = zoomText
}
setDimensionsText(dimensionsText: string) {
this.dimensionsTextHTML.innerText = dimensionsText
}
}
class ToolManager {
@@ -4022,7 +3814,6 @@ 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
@@ -4030,11 +3821,8 @@ 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 }
@@ -4090,11 +3878,6 @@ class PanAndZoomManager {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
@@ -4271,25 +4054,14 @@ 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)
@@ -4299,125 +4071,51 @@ 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 - sidePanelWidth - toolPanelWidth
let availableWidth = rootElement.clientWidth - 2 * sidePanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
// Initial dimensions
let drawWidth = image.width
let drawHeight = image.height
let aspectRatio = image.width / image.height
// First check if width needs scaling
if (drawWidth > availableWidth) {
drawWidth = availableWidth
drawHeight = (drawWidth / image.width) * image.height
}
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
// Then check if height needs scaling
if (drawHeight > availableHeight) {
drawHeight = availableHeight
drawWidth = (drawHeight / image.height) * image.width
}
if (this.image === null) {
this.image = image
}
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
this.zoom_ratio = drawWidth / image.width
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
// 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
await this.invalidatePanZoom()
}
//probably move to PanZoomManager
async invalidatePanZoom() {
// Single validation check upfront
if (
@@ -4434,6 +4132,10 @@ 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')
@@ -4536,9 +4238,6 @@ class MessageBroker {
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
}
/**
@@ -4726,38 +4425,12 @@ app.registerExtension({
settings: [
{
id: 'Comfy.MaskEditor.UseNewEditor',
category: ['Mask Editor', 'NewEditor'],
category: ['Comfy', 'Masking'],
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

@@ -54,7 +54,6 @@ app.registerExtension({
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] }
}
LiteGraph.registered_slot_in_types[lowerType].nodes.push(
// @ts-expect-error ComfyNode
nodeType.comfyClass
)
}
@@ -72,7 +71,6 @@ app.registerExtension({
if (!(type in LiteGraph.registered_slot_out_types)) {
LiteGraph.registered_slot_out_types[type] = { nodes: [] }
}
// @ts-expect-error ComfyNode
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass)
if (!LiteGraph.slot_types_out.includes(type)) {

View File

@@ -81,7 +81,6 @@ app.registerExtension({
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (
// @ts-expect-error ComfyNode
['LoadAudio', 'SaveAudio', 'PreviewAudio'].includes(nodeType.comfyClass)
) {
nodeData.input.required.audioUI = ['AUDIO_UI']

View File

@@ -73,22 +73,17 @@ app.registerExtension({
const canvas = document.createElement('canvas')
const capture = () => {
// @ts-expect-error widget value type narrow down
canvas.width = w.value
// @ts-expect-error widget value type narrow down
canvas.height = h.value
const ctx = canvas.getContext('2d')
// @ts-expect-error widget value type narrow down
ctx.drawImage(video, 0, 0, w.value, h.value)
const data = canvas.toDataURL('image/png')
const img = new Image()
img.onload = () => {
// @ts-expect-error adding extra property
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
// @ts-expect-error accessing extra property
node.setSizeForImage?.()
})
}
@@ -102,14 +97,11 @@ app.registerExtension({
capture
)
btn.disabled = true
// @ts-expect-error hacky override
btn.serializeValue = () => undefined
// @ts-expect-error hacky override
camera.serializeValue = async () => {
if (captureOnQueue.value) {
capture()
// @ts-expect-error accessing extra property
} else if (!node.imgs?.length) {
const err = `No webcam image captured`
useToastStore().addAlert(err)

View File

@@ -724,7 +724,6 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Add menu options to convert to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (widget) {
const config = getConfig.call(this, widget.name) ?? [
widget.type,
@@ -747,10 +746,8 @@ app.registerExtension({
if (w.options?.forceInput) {
continue
}
// @ts-expect-error custom widget type
if (w.type === CONVERTED_TYPE) {
toWidget.push({
// @ts-expect-error never
content: `Convert ${w.name} to widget`,
callback: () => convertToWidget(this, w)
})
@@ -863,9 +860,7 @@ app.registerExtension({
for (const input of this.inputs) {
if (input.widget && !input.widget[GET_CONFIG]) {
input.widget[GET_CONFIG] = () =>
// @ts-expect-error input.widget has unknown type
getConfig.call(this, input.widget.name)
// @ts-expect-error input.widget has unknown type
const w = this.widgets.find((w) => w.name === input.widget.name)
if (w) {
hideWidget(this, w)
@@ -940,7 +935,6 @@ app.registerExtension({
originNode,
originSlot
) {
// @ts-expect-error onConnectInput has 5 arguments
const v = onConnectInput?.(this, arguments)
// Not a combo, ignore
if (type !== 'COMBO') return v

View File

@@ -1,37 +0,0 @@
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,18 +0,0 @@
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import DocumentationSidebarTab from '@/components/sidebar/tabs/DocumentationSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useDocumentationSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'documentation',
icon: 'mdi mdi-help',
title: t('sideToolbar.documentation'),
tooltip: t('sideToolbar.documentation'),
component: markRaw(DocumentationSidebarTab),
type: 'vue'
}
}

View File

@@ -18,8 +18,8 @@ export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
iconBadge: () => {
if (isElectron()) {
const electronDownloadStore = useElectronDownloadStore()
if (electronDownloadStore.inProgressDownloads.length > 0) {
return electronDownloadStore.inProgressDownloads.length.toString()
if (electronDownloadStore.downloads.length > 0) {
return electronDownloadStore.downloads.length.toString()
}
}

View File

@@ -1,16 +1,453 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en'
import zh from './locales/zh'
import ru from './locales/ru'
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
}
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: {
en,
zh,
ru
}
messages
})

View File

@@ -1,225 +0,0 @@
export default {
welcome: {
title: 'Welcome to ComfyUI',
getStarted: 'Get Started'
},
notSupported: {
title: 'Your device is not supported',
message: 'Only following devices are supported:',
learnMore: 'Learn More',
reportIssue: 'Report Issue',
supportedDevices: {
macos: 'MacOS (M1 or later)',
windows: 'Windows (Nvidia GPU with CUDA support)'
}
},
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 Desktop'
}
},
serverConfig: {
modifiedConfigs:
'You have modified the following server configurations. Restart to apply changes.',
revertChanges: 'Revert Changes',
restart: 'Restart'
},
empty: 'Empty',
noWorkflowsFound: 'No workflows found.',
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',
documentation: 'Display documentation for nodes',
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'
}
}

View File

@@ -1,122 +0,0 @@
export default {
empty: 'Пусто',
noWorkflowsFound: 'Рабочие процессы не найдены.',
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: 'Переключить видимость ссылок'
}
}

View File

@@ -1,122 +0,0 @@
export default {
empty: '空',
noWorkflowsFound: '未找到工作流',
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

@@ -57,12 +57,6 @@ const router = createRouter({
name: 'WelcomeView',
component: () => import('@/views/WelcomeView.vue'),
beforeEnter: guardElectronAccess
},
{
path: 'not-supported',
name: 'NotSupportedView',
component: () => import('@/views/NotSupportedView.vue'),
beforeEnter: guardElectronAccess
}
]
}

View File

@@ -137,7 +137,6 @@ export class ComfyApp {
canvas: LGraphCanvas
dragOverNode: LGraphNode | null
canvasEl: HTMLCanvasElement
tooltipCallback?: (node: LGraphNode, name: string, value?: string) => boolean
// x, y, scale
zoom_drag_start: [number, number, number] | null
lastNodeErrors: any[] | null
@@ -146,6 +145,7 @@ export class ComfyApp {
configuringGraph: boolean
isNewUserSession: boolean
storageLocation: StorageLocation
multiUserServer: boolean
ctx: CanvasRenderingContext2D
bodyTop: HTMLElement
bodyLeft: HTMLElement
@@ -1728,6 +1728,7 @@ export class ComfyApp {
return
}
this.multiUserServer = true
let user = localStorage['Comfy.userId']
const users = userConfig.users ?? {}
if (!user || !users[user]) {

View File

@@ -388,11 +388,8 @@ export class ChangeTracker {
return false
}
// Compare extra properties ignoring ds
if (
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
)
return false
// Reroutes (schema 0.4)
if (!_.isEqual(a.extra?.reroutes, b.extra?.reroutes)) 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 && !existingWorkflow.isTemporary) {
if (existingWorkflow) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Overwrite existing file?',
message: `"${newPath}" already exists. Do you want to overwrite it?`,

View File

@@ -2,126 +2,52 @@
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
import { defineStore } from 'pinia'
import { ref, type Component, markRaw } from 'vue'
import { ref, shallowRef, type Component, markRaw } from 'vue'
interface DialogComponentProps {
maximizable?: boolean
onClose?: () => void
}
interface DialogInstance {
key: string
visible: boolean
title?: string
headerComponent?: Component
component: Component
contentProps: Record<string, any>
dialogComponentProps: Record<string, any>
}
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
}) {
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
}
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>({})
function showDialog(options: {
key?: string
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
}) {
const dialogKey = options.key || genDialogKey()
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 || {}
}
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
if (dialog) {
dialog.visible = true
riseDialog(dialog)
} else {
dialog = createDialog({ ...options, key: dialogKey })
function closeDialog() {
if (dialogComponentProps.value.onClose) {
dialogComponentProps.value.onClose()
}
return dialog
isVisible.value = false
}
return {
dialogStack,
riseDialog,
isVisible,
title,
headerComponent,
component,
props,
dialogComponentProps,
showDialog,
closeDialog
}

View File

@@ -1,8 +1,8 @@
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { isElectron, electronAPI } from '@/utils/envUtil'
import {
type DownloadState,
import type {
DownloadState,
DownloadStatus
} from '@comfyorg/comfyui-electron-types'
@@ -69,11 +69,6 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
resume,
cancel,
findByUrl,
initialize,
inProgressDownloads: computed(() =>
downloads.value.filter(
({ status }) => status !== DownloadStatus.COMPLETED
)
)
initialize
}
})

View File

@@ -1,5 +1,4 @@
import { LGraphNode, LGraphGroup, LGraphCanvas } from '@comfyorg/litegraph'
import type { ComfyNodeItem } from '@/types/comfy'
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
@@ -11,14 +10,6 @@ export const useTitleEditorStore = defineStore('titleEditor', () => {
}
})
export const useHoveredItemStore = defineStore('hoveredItem', () => {
const hoveredItem = shallowRef<ComfyNodeItem | null>(null)
return {
hoveredItem
}
})
export const useCanvasStore = defineStore('canvas', () => {
/**
* The LGraphCanvas instance.

View File

@@ -5,8 +5,7 @@ import {
import {
type ComfyNodeDef,
type ComfyInputsSpec as ComfyInputsSpecSchema,
type InputSpec,
type DescriptionSpec
type InputSpec
} from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
@@ -159,7 +158,7 @@ export class ComfyNodeDefImpl {
display_name: string
category: string
python_module: string
description: DescriptionSpec
description: string
deprecated: boolean
experimental: boolean
input: ComfyInputsSpec

View File

@@ -30,6 +30,7 @@ export class ResultItemImpl {
filename: string
subfolder: string
type: string
cached: boolean
nodeId: NodeId
// 'audio' | 'images' | ...
@@ -43,6 +44,7 @@ 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
@@ -149,6 +151,13 @@ 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(
@@ -156,7 +165,8 @@ export class TaskItemImpl {
new ResultItemImpl({
...item,
nodeId,
mediaType
mediaType,
cached: cachedEntries.has(nodeId)
})
)
)

View File

@@ -1,39 +1,18 @@
import { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { ServerConfig } 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<ServerConfigValue>>
>({})
const serverConfigById = ref<Record<string, ServerConfigWithValue<any>>>({})
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<ServerConfigValue>[]>
Record<string, ServerConfigWithValue<any>[]>
>(() => {
return serverConfigs.value.reduce(
(acc, config) => {
@@ -42,17 +21,15 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
acc[category].push(config)
return acc
},
{} as Record<string, ServerConfigWithValue<ServerConfigValue>[]>
{} as Record<string, ServerConfigWithValue<any>[]>
)
})
const serverConfigValues = computed<Record<string, ServerConfigValue>>(() => {
const serverConfigValues = computed<Record<string, any>>(() => {
return Object.fromEntries(
serverConfigs.value.map((config) => {
return [
config.id,
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
config.value === config.defaultValue || !config.value
? undefined
: config.value
]
@@ -60,18 +37,10 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
)
})
const launchArgs = computed<Record<string, string>>(() => {
const args: Record<
string,
Omit<ServerConfigValue, 'undefined' | 'null'>
> = Object.assign(
return Object.assign(
{},
...serverConfigs.value.map((config) => {
// Filter out configs that have the default value or undefined | null value
if (
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
) {
if (config.value === config.defaultValue || !config.value) {
return {}
}
return config.getValue
@@ -79,36 +48,16 @@ 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<ServerConfigValue>[],
values: Record<string, ServerConfigValue>
configs: ServerConfig<any>[],
values: Record<string, any>
) {
for (const config of configs) {
const value = values[config.id] ?? config.defaultValue
serverConfigById.value[config.id] = {
...config,
value,
initialValue: value
value: values[config.id] ?? config.defaultValue
}
}
}
@@ -116,12 +65,9 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
return {
serverConfigById,
serverConfigs,
modifiedConfigs,
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
revertChanges,
loadServerConfig
}
})

View File

@@ -2,7 +2,6 @@ import { useModelLibrarySidebarTab } from '@/hooks/sidebarTabs/modelLibrarySideb
import { useNodeLibrarySidebarTab } from '@/hooks/sidebarTabs/nodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/hooks/sidebarTabs/queueSidebarTab'
import { useWorkflowsSidebarTab } from '@/hooks/sidebarTabs/workflowsSidebarTab'
import { useDocumentationSidebarTab } from '@/hooks/sidebarTabs/documentationSidebarTab'
import { SidebarTabExtension } from '@/types/extensionTypes'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -58,7 +57,6 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
registerSidebarTab(useDocumentationSidebarTab())
}
return {

View File

@@ -356,11 +356,6 @@ const zComfyComboOutput = z.array(z.any())
const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
const zDescriptionSpec = z.union([
z.string(),
z.tuple([z.string(), z.string()]),
z.tuple([z.string(), z.string(), z.record(z.string(), z.any())])
])
const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
@@ -370,7 +365,7 @@ const zComfyNodeDef = z.object({
output_tooltips: z.array(z.string()).optional(),
name: z.string(),
display_name: z.string(),
description: zDescriptionSpec,
description: z.string(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -383,7 +378,6 @@ export type InputSpec = z.infer<typeof zInputSpec>
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type DescriptionSpec = z.infer<typeof zDescriptionSpec>
export function validateComfyNodeDef(
data: any,
@@ -460,6 +454,11 @@ 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({
@@ -512,6 +511,8 @@ 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

@@ -1,12 +1,11 @@
import type { LGraphNode, IWidget } from '@comfyorg/litegraph'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyNodeDef, DescriptionSpec } from '@/types/apiTypes'
import type { LGraphNode } from './litegraph'
import type { ComfyApp } from '../scripts/app'
import type { ComfyNodeDef } from '@/types/apiTypes'
import type { Keybinding } from '@/types/keyBindingTypes'
import type { ComfyCommand } from '@/stores/commandStore'
import type { SettingParams } from '@/types/settingTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
import type { SettingParams } from './settingTypes'
import type { BottomPanelExtension } from './extensionTypes'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export type Widgets = Record<string, ComfyWidgetConstructor>
@@ -160,8 +159,20 @@ export interface ComfyExtension {
[key: string]: any
}
export type ComfyNodeItem =
| { node: LGraphNode; type: 'Title' }
| { node: LGraphNode; type: 'Output'; outputSlot: number }
| { node: LGraphNode; type: 'Input'; inputName: string }
| { node: LGraphNode; type: 'Widget'; widget: IWidget }
/**
* @deprecated Use ComfyNodeDef instead
*/
export type ComfyObjectInfo = {
name: string
display_name?: string
description?: string
category: string
input?: {
required?: Record<string, ComfyObjectInfoConfig>
optional?: Record<string, ComfyObjectInfoConfig>
}
output?: string[]
output_name: string[]
}
export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any]

View File

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

View File

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

View File

@@ -19,10 +19,7 @@ import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { api } from '@/scripts/api'
import { StatusWsMessageStatus } from '@/types/apiTypes'
import {
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from '@/i18n'
@@ -38,8 +35,6 @@ 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()
@@ -97,12 +92,6 @@ watchEffect(() => {
}
})
watchEffect(() => {
useQueueStore().maxHistoryItems = settingStore.get(
'Comfy.Queue.MaxHistoryItems'
)
})
const init = () => {
settingStore.addSettings(app.ui.settings)
useKeybindingStore().loadCoreKeybindings()
@@ -162,12 +151,6 @@ 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

@@ -1,62 +0,0 @@
<template>
<div
class="font-sans w-screen h-screen flex items-center m-0 text-neutral-900 bg-neutral-300 pointer-events-auto"
>
<div class="flex-grow flex items-center justify-center">
<div class="flex flex-col gap-8 p-8">
<!-- Header -->
<h1 class="text-4xl font-bold text-red-500">
{{ $t('notSupported.title') }}
</h1>
<!-- Message -->
<div class="space-y-4">
<p class="text-xl">
{{ $t('notSupported.message') }}
</p>
<ul class="list-disc list-inside space-y-1 text-neutral-800">
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
</ul>
</div>
<!-- Actions -->
<div class="flex gap-4">
<Button
:label="$t('notSupported.learnMore')"
icon="pi pi-github"
@click="openDocs"
severity="secondary"
/>
<Button
:label="$t('notSupported.reportIssue')"
icon="pi pi-flag"
@click="reportIssue"
severity="danger"
/>
</div>
</div>
</div>
<!-- Right side image -->
<div class="h-screen flex-grow-0">
<img
src="/assets/images/sad_girl.png"
alt="Sad girl illustration"
class="h-full object-cover"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const openDocs = () => {
window.open('https://github.com/Comfy-Org/desktop', '_blank')
}
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
</script>

View File

@@ -2,14 +2,9 @@
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<h2 class="text-2xl font-bold">
{{ t(`serverStart.process.${status}`) }}
<span v-if="status === ProgressStatus.ERROR">
v{{ electronVersion }}
</span>
</h2>
<h2 class="text-2xl font-bold">{{ t(`serverStart.process.${status}`) }}</h2>
<div
v-if="status === ProgressStatus.ERROR"
v-if="status == ProgressStatus.ERROR"
class="flex items-center my-4 gap-2"
>
<Button
@@ -48,7 +43,6 @@ const electron = electronAPI()
const { t } = useI18n()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const electronVersion = ref<string>('')
let xterm: Terminal | undefined
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
@@ -78,10 +72,9 @@ const reportIssue = () => {
}
const openLogs = () => electron.openLogsFolder()
onMounted(async () => {
onMounted(() => {
electron.sendReady()
electron.onProgressUpdate(updateProgress)
electronVersion.value = await electron.getElectronVersion()
})
</script>

View File

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

View File

@@ -170,116 +170,20 @@ 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.config4', defaultValue: null }
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' }
]
store.loadServerConfig(configs, {
'test.config1': undefined,
'test.config2': null,
'test.config3': '',
'test.config4': 0
'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)
expect(Object.keys(store.serverConfigValues)).toEqual([
'test.config1',
'test.config2',
'test.config3'
])
})
})

View File

@@ -73,6 +73,7 @@ export async function checkBeforeAndAfterReload(graph, cb) {
* @param { string } name
* @param { Record<string, string | [string | string[], any]> } input
* @param { (string | string[])[] | Record<string, string | string[]> } output
* @returns { Record<string, import("../../src/types/comfy").ComfyObjectInfo> }
*/
export function makeNodeDef(name, input, output = {}) {
const nodeDef = {

View File

@@ -29,10 +29,14 @@ export interface APIConfig {
userData?: Record<string, any>
}
/**
* @typedef { import("../../src/types/comfy").ComfyObjectInfo } ComfyObjectInfo
*/
/**
* @param {{
* mockExtensions?: string[],
* mockNodeDefs?: Record<string, any>,
* mockNodeDefs?: Record<string, ComfyObjectInfo>,
* settings?: Record<string, string>
* userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean },
* userData?: Record<string, any>

View File

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