Merge remote-tracking branch 'origin/main' into feature/show_dropped_image_in_output_node

This commit is contained in:
bezo97
2024-10-13 15:43:01 +02:00
140 changed files with 2593 additions and 926 deletions

View File

@@ -15,8 +15,15 @@ import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useEventListener } from '@vueuse/core'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const workspaceStore = useWorkspaceStore()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version

View File

@@ -83,6 +83,7 @@ const setInitialPosition = () => {
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
@@ -97,6 +98,7 @@ const setInitialPosition = () => {
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
}
}
onMounted(setInitialPosition)
@@ -106,6 +108,31 @@ watch(visible, (newVisible) => {
}
})
const lastDragState = ref({
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
})
const captureLastDragState = () => {
lastDragState.value = {
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
}
}
watch(
isDragging,
(newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
captureLastDragState()
}
},
{ immediate: true }
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const screenWidth = window.innerWidth
@@ -113,10 +140,34 @@ const adjustMenuPosition = () => {
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Adjust x position if menu is off-screen horizontally
x.value = clamp(x.value, 0, screenWidth - menuWidth)
// Calculate the distance from each edge
const distanceRight =
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
const distanceBottom =
lastDragState.value.windowHeight - (lastDragState.value.y + menuHeight)
// Adjust y position if menu is off-screen vertically
// Determine if the menu is closer to right/bottom or left/top
const anchorRight = distanceRight < lastDragState.value.x
const anchorBottom = distanceBottom < lastDragState.value.y
// Calculate new position
if (anchorRight) {
x.value =
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
} else {
x.value = lastDragState.value.x
}
if (anchorBottom) {
y.value =
screenHeight -
(lastDragState.value.windowHeight - lastDragState.value.y)
} else {
y.value = lastDragState.value.y
}
// Ensure the menu stays within the screen bounds
x.value = clamp(x.value, 0, screenWidth - menuWidth)
y.value = clamp(y.value, 0, screenHeight - menuHeight)
}
}

View File

@@ -3,13 +3,20 @@
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
:icon="activeQueueModeMenuItem.icon"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="$t('menu.queueWorkflow')"
v-tooltip.bottom="
workspaceStore.shiftDown
? $t('menu.queueWorkflowFront')
: $t('menu.queueWorkflow')
"
>
<template #icon>
<i-lucide:list-start v-if="workspaceStore.shiftDown" />
<i v-else :class="activeQueueModeMenuItem.icon" />
</template>
<template #item="{ item }">
<Button
:label="item.label"
@@ -58,7 +65,9 @@ import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())

View File

@@ -21,8 +21,8 @@
<slot name="after-label" :node="props.node"></slot>
</span>
<Badge
v-if="!props.node.leaf"
:value="props.node.badgeText ?? props.node.totalLeaves"
v-if="showNodeBadgeText"
:value="nodeBadgeText"
severity="secondary"
class="leaf-count-badge"
/>
@@ -34,12 +34,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, inject, Ref, computed } from 'vue'
import { ref, inject, Ref, computed } from 'vue'
import Badge from 'primevue/badge'
import {
dropTargetForElements,
draggable
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type {
TreeExplorerDragAndDropData,
RenderedTreeExplorerNode,
@@ -47,6 +43,7 @@ import type {
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
import { usePragmaticDraggable, usePragmaticDroppable } from '@/hooks/dndHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -62,6 +59,17 @@ const emit = defineEmits<{
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const nodeBadgeText = computed<string>(() => {
if (props.node.leaf) {
return ''
}
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
}
return props.node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const labelEditable = computed<boolean>(() => !!props.node.handleRename)
const renameEditingNode =
inject<Ref<TreeExplorerNode | null>>('renameEditingNode')
@@ -78,54 +86,44 @@ const handleRename = errorHandling.wrapWithErrorHandlingAsync(
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
let dropTargetCleanup = () => {}
let draggableCleanup = () => {}
onMounted(() => {
treeNodeElement.value = container.value?.closest(
'.p-tree-node-content'
) as HTMLElement
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
}
if (props.node.draggable) {
draggableCleanup = draggable({
element: treeNodeElement.value,
getInitialData() {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
})
onUnmounted(() => {
dropTargetCleanup()
draggableCleanup()
})
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (props.node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: props.node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node)
})
}
if (props.node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}
},
onDragEnter: (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
canDrop.value = true
}
},
onDragLeave: () => {
canDrop.value = false
}
})
}
</script>
<style scoped>

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeAll } from 'vitest'
import EditableText from '../EditableText.vue'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -12,13 +12,15 @@
@maximize="onMaximize"
@unmaximize="onUnmaximize"
:pt="{ header: 'pb-0' }"
:aria-labelledby="headerId"
>
<template #header>
<component
v-if="dialogStore.headerComponent"
:is="dialogStore.headerComponent"
:id="headerId"
/>
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
<h3 v-else :id="headerId">{{ dialogStore.title || ' ' }}</h3>
</template>
<component :is="dialogStore.component" v-bind="contentProps" />
@@ -48,4 +50,6 @@ const contentProps = computed(() => ({
...dialogStore.props,
maximized: maximized.value
}))
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
</script>

View File

@@ -1,8 +1,8 @@
<template>
<div class="settings-container">
<div class="settings-sidebar">
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-64">
<SearchBox
class="settings-search-box"
class="settings-search-box w-full mb-2"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchSettings') + '...'"
@@ -13,11 +13,11 @@
optionLabel="label"
scrollHeight="100%"
:disabled="inSearch"
:pt="{ root: { class: 'border-none' } }"
class="border-none w-full"
/>
</div>
</ScrollPanel>
<Divider layout="vertical" />
<div class="settings-content">
<ScrollPanel class="settings-content flex-grow">
<Tabs :value="tabValue">
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">
@@ -55,24 +55,35 @@
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<KeybindingPanel />
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="extension" value="Extension">
<ExtensionPanel />
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, defineAsyncComponent } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
@@ -80,8 +91,13 @@ import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import KeybindingPanel from './setting/KeybindingPanel.vue'
import ExtensionPanel from './setting/ExtensionPanel.vue'
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
)
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
interface ISettingGroup {
label: string
@@ -193,44 +209,8 @@ const tabValue = computed(() =>
display: flex;
height: 70vh;
width: 60vw;
max-width: 1000px;
max-width: 1024px;
overflow: hidden;
/* Prevents container from scrolling */
}
.settings-sidebar {
width: 250px;
flex-shrink: 0;
/* Prevents sidebar from shrinking */
overflow-y: auto;
padding: 10px;
}
.settings-search-box {
width: 100%;
margin-bottom: 10px;
}
.settings-content {
flex-grow: 1;
overflow-y: auto;
/* Allows vertical scrolling */
}
/* Ensure the Listbox takes full width of the sidebar */
.settings-sidebar :deep(.p-listbox) {
width: 100%;
}
/* Optional: Style scrollbars for webkit browsers */
.settings-sidebar::-webkit-scrollbar,
.settings-content::-webkit-scrollbar {
width: 1px;
}
.settings-sidebar::-webkit-scrollbar-thumb,
.settings-content::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (max-width: 768px) {

View File

@@ -13,7 +13,7 @@
<canvas ref="canvasRef" id="graph-canvas" tabindex="1" />
</teleport>
<NodeSearchboxPopover />
<NodeTooltip />
<NodeTooltip v-if="tooltipEnabled" />
</template>
<script setup lang="ts">
@@ -22,15 +22,10 @@ import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import { ref, computed, onUnmounted, onMounted, watchEffect } from 'vue'
import { ref, computed, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
LiteGraph,
@@ -44,7 +39,6 @@ import {
LGraphBadge
} from '@comfyorg/litegraph'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyModelDef } from '@/stores/modelStore'
import {
@@ -52,7 +46,7 @@ import {
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -67,6 +61,7 @@ const betaMenuEnabled = computed(
const canvasMenuEnabled = computed(() =>
settingStore.get('Comfy.Graph.CanvasMenu')
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
@@ -107,12 +102,12 @@ watchEffect(() => {
watchEffect(() => {
if (!canvasStore.canvas) return
if (canvasStore.draggingCanvas) {
if (canvasStore.canvas.dragging_canvas) {
canvasStore.canvas.canvas.style.cursor = 'grabbing'
return
}
if (canvasStore.readOnly) {
if (canvasStore.canvas.read_only) {
canvasStore.canvas.canvas.style.cursor = 'grab'
return
}
@@ -120,7 +115,60 @@ watchEffect(() => {
canvasStore.canvas.canvas.style.cursor = 'default'
})
let dropTargetCleanup = () => {}
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = comfyApp.addNodeOnGraph(provider.nodeDef, {
pos
})
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
onMounted(async () => {
// Backward compatible
@@ -145,78 +193,6 @@ onMounted(async () => {
window['app'] = comfyApp
window['graph'] = comfyApp.graph
dropTargetCleanup = dropTargetForElements({
element: canvasRef.value,
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = comfyApp.addNodeOnGraph(provider.nodeDef, {
pos
})
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
// Load keybindings. This must be done after comfyApp loads settings.
useKeybindingStore().loadUserKeybindings()
// Migrate legacy bookmarks
useNodeBookmarkStore().migrateLegacyBookmarks()
// Explicitly initialize nodeSearchService to avoid indexing delay when
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
// Non-blocking load of node frequencies
useNodeFrequencyStore().loadNodeFrequencies()
emit('ready')
})
onUnmounted(() => {
dropTargetCleanup()
})
</script>

View File

@@ -26,13 +26,16 @@
severity="secondary"
v-tooltip.left="
t(
'graphCanvasMenu.' + (canvasStore.readOnly ? 'panMode' : 'selectMode')
'graphCanvasMenu.' +
(canvasStore.canvas?.read_only ? 'panMode' : 'selectMode')
) + ' (Space)'
"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLock')"
>
<template #icon>
<i-material-symbols:pan-tool-outline v-if="canvasStore.readOnly" />
<i-material-symbols:pan-tool-outline
v-if="canvasStore.canvas?.read_only"
/>
<i-simple-line-icons:cursor v-else />
</template>
</Button>

View File

@@ -10,15 +10,14 @@
</template>
<script setup lang="ts">
import { nextTick, ref, onBeforeUnmount, 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 { useSettingStore } from '@/stores/settingStore'
import { useEventListener } from '@vueuse/core'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
@@ -129,24 +128,8 @@ const onMouseMove = (e: MouseEvent) => {
idleTimeout = window.setTimeout(onIdle, 500)
}
watch(
() => settingStore.get('Comfy.EnableTooltips'),
(enabled) => {
if (enabled) {
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('click', hideTooltip)
} else {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('click', hideTooltip)
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('click', hideTooltip)
})
useEventListener(window, 'mousemove', onMouseMove)
useEventListener(window, 'click', hideTooltip)
</script>
<style lang="css" scoped>

View File

@@ -6,7 +6,7 @@
:key="tab.id"
:icon="tab.icon"
:iconBadge="tab.iconBadge"
:tooltip="tab.tooltip"
:tooltip="tab.tooltip + getTabTooltipSuffix(tab)"
:selected="tab.id === selectedTab?.id"
:class="tab.id + '-tab-button'"
@click="onTabClick(tab)"
@@ -45,6 +45,7 @@ import {
CustomSidebarTabExtension,
SidebarTabExtension
} from '@/types/extensionTypes'
import { useKeybindingStore } from '@/stores/keybindingStore'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -74,6 +75,14 @@ onBeforeUnmount(() => {
}
})
})
const keybindingStore = useKeybindingStore()
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const keybinding = keybindingStore.getKeybindingByCommandId(
`Workspace.ToggleSidebarTab.${tab.id}`
)
return keybinding ? ` (${keybinding.combo.toString()})` : ''
}
</script>
<style>

View File

@@ -8,21 +8,19 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import SidebarIcon from './SidebarIcon.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
const previousDarkTheme = ref('dark')
const currentTheme = computed(() => useSettingStore().get('Comfy.ColorPalette'))
const isDarkMode = computed(() => currentTheme.value !== 'light')
const icon = computed(() => (isDarkMode.value ? 'pi pi-moon' : 'pi pi-sun'))
const settingStore = useSettingStore()
const currentTheme = computed(() => settingStore.get('Comfy.ColorPalette'))
const icon = computed(() =>
currentTheme.value !== 'light' ? 'pi pi-moon' : 'pi pi-sun'
)
const commandStore = useCommandStore()
const toggleTheme = () => {
if (isDarkMode.value) {
previousDarkTheme.value = currentTheme.value
useSettingStore().set('Comfy.ColorPalette', 'light')
} else {
useSettingStore().set('Comfy.ColorPalette', previousDarkTheme.value)
}
commandStore.execute('Comfy.ToggleTheme')
}
</script>

View File

@@ -1,28 +1,26 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
<template #tool-buttons> </template>
<SidebarTabTemplate
:title="$t('sideToolbar.modelLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #header>
<SearchBox
class="model-lib-search-box p-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
/>
</template>
<template #body>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="model-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
:placeholder="$t('searchModels') + '...'"
/>
</div>
<div class="flex-grow overflow-y-auto">
<TreeExplorer
class="model-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
<TreeExplorer
class="model-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="model-library-model-preview-container" />
@@ -70,7 +68,8 @@ const root: ComputedRef<TreeNode> = computed(() => {
if (Object.values(models.models).length) {
modelList.push(...Object.values(models.models))
} else {
const fakeModel = new ComfyModelDef('(No Content)', folder)
// ModelDef with key 'folder/a/b/c/' is treated as empty folder
const fakeModel = new ComfyModelDef('', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
@@ -83,15 +82,12 @@ const root: ComputedRef<TreeNode> = computed(() => {
if (searchQuery.value) {
const search = searchQuery.value.toLocaleLowerCase()
modelList = modelList.filter((model: ComfyModelDef) => {
return model.file_name.toLocaleLowerCase().includes(search)
return model.searchable.includes(search)
})
}
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) => {
return [
model.directory,
...model.file_name.replaceAll('\\', '/').split('/')
]
})
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) =>
model.key.split('/')
)
return tree
})
@@ -102,18 +98,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
const model: ComfyModelDef | null =
node.leaf && node.data ? node.data : null
if (model?.is_fake_object) {
if (model.file_name === '(No Content)') {
return {
key: node.key,
label: t('noContent'),
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-file'
},
children: []
}
} else {
if (model.file_name === 'Loading') {
return {
key: node.key,
label: t('loading') + '...',
@@ -151,10 +136,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
if (node.children?.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.data?.is_fake_object) {
if (onlyChild.data.file_name === '(No Content)') {
return '0'
} else if (onlyChild.data.file_name === 'Loading') {
return '?'
if (onlyChild.data.file_name === 'Loading') {
return ''
}
}
}
@@ -214,15 +197,9 @@ watch(
)
</script>
<style>
.pi-fake-spacer {
<style scoped>
:deep(.pi-fake-spacer) {
height: 1px;
width: 16px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodeLibrary')">
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
class="new-folder-button"
@@ -18,44 +21,41 @@
v-tooltip="$t('sideToolbar.nodeLibraryTab.sortOrder')"
/>
</template>
<template #body>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="node-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<template #header>
<SearchBox
class="node-lib-search-box p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
@show-filter="($event) => searchFilter.toggle($event)"
@remove-filter="onRemoveFilter"
:placeholder="$t('searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
/>
<Popover ref="searchFilter" class="node-lib-filter-popup">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</div>
<div class="flex-grow overflow-y-auto">
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-if="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
/>
<TreeExplorer
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @addFilter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
class="node-lib-tree-explorer py-0"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<div id="node-library-node-preview-container" />
@@ -193,23 +193,3 @@ const onRemoveFilter = (filterAndValue) => {
handleSearch(searchQuery.value)
}
</script>
<style>
.node-lib-filter-popup {
margin-left: -13px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
:deep(.node-lib-bookmark-tree-explorer) {
padding-bottom: 2px;
}
:deep(.p-divider) {
margin: var(--comfy-tree-explorer-item-padding) 0px;
}
</style>

View File

@@ -249,7 +249,8 @@ const menuItems = computed<MenuItem[]>(() => [
{
label: t('loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow()
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('goToNode'),
@@ -327,6 +328,14 @@ watch(
overflow-y: auto;
}
.scroll-container::-webkit-scrollbar {
width: 1px;
}
.scroll-container::-webkit-scrollbar-thumb {
background-color: transparent;
}
.queue-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -1,66 +1,36 @@
<template>
<div class="comfy-vue-side-bar-container">
<Toolbar class="comfy-vue-side-bar-header">
<template #start>
<span class="comfy-vue-side-bar-header-span">{{
props.title.toUpperCase()
}}</span>
</template>
<template #end>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<div class="comfy-vue-side-bar-body">
<slot name="body"></slot>
<div
class="comfy-vue-side-bar-container flex flex-col h-full"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
<Toolbar
class="flex-shrink-0 border-x-0 border-t-0 rounded-none px-2 py-1 min-h-10"
>
<template #start>
<span class="text-sm">{{ props.title.toUpperCase() }}</span>
</template>
<template #end>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<slot name="header"></slot>
</div>
<!-- h-0 to force scrollpanel to flex-grow -->
<ScrollPanel class="comfy-vue-side-bar-body flex-grow h-0">
<div class="h-full">
<slot name="body"></slot>
</div>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import Toolbar from 'primevue/toolbar'
import ScrollPanel from 'primevue/scrollpanel'
const props = defineProps({
title: {
type: String,
required: true
}
})
const props = defineProps<{
title: string
class?: string
}>()
</script>
<style scoped>
.comfy-vue-side-bar-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.comfy-vue-side-bar-header {
flex-shrink: 0;
border-left: none;
border-right: none;
border-top: none;
border-radius: 0;
padding: 0.25rem 1rem;
min-height: 2.5rem;
}
.comfy-vue-side-bar-header-span {
font-size: small;
}
.comfy-vue-side-bar-body {
flex-grow: 1;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
.comfy-vue-side-bar-body::-webkit-scrollbar {
width: 1px;
}
.comfy-vue-side-bar-body::-webkit-scrollbar-thumb {
background-color: transparent;
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.workflows')">
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
class="browse-templates-button"
@@ -23,13 +26,15 @@
text
/>
</template>
<template #body>
<template #header>
<SearchBox
class="workflows-search-box mx-4 my-4"
class="workflows-search-box p-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchWorkflows') + '...'"
/>
</template>
<template #body>
<div class="comfyui-workflows-panel" v-if="!isSearching">
<div
class="comfyui-workflows-open"
@@ -214,9 +219,3 @@ const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.name}.json`]: true
}))
</script>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<TreeExplorer
class="node-lib-bookmark-tree-explorer"
class="node-lib-bookmark-tree-explorer py-0"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"

View File

@@ -25,7 +25,9 @@
:src="item.url"
:contain="false"
class="galleria-image"
v-if="item.isImage"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
</template>
</Galleria>
</template>
@@ -35,6 +37,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
import Galleria from 'primevue/galleria'
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)

View File

@@ -1,27 +1,25 @@
<template>
<div class="result-container" ref="resultContainer">
<template
v-if="result.mediaType === 'images' || result.mediaType === 'gifs'"
>
<ComfyImage
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<div class="image-preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</template>
<!-- TODO: handle more media types -->
<ComfyImage
v-if="result.isImage"
:src="result.url"
class="task-output-image"
:contain="imageFit === 'contain'"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
</div>
<div v-if="result.supportsPreview" class="preview-mask">
<Button
icon="pi pi-eye"
severity="secondary"
@click="emit('preview', result)"
rounded
/>
</div>
</div>
</template>
@@ -31,6 +29,7 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{
result: ResultItemImpl
@@ -67,7 +66,7 @@ onMounted(() => {
align-items: center;
}
.image-preview-mask {
.preview-mask {
position: absolute;
left: 50%;
top: 50%;
@@ -80,7 +79,7 @@ onMounted(() => {
z-index: 1;
}
.result-container:hover .image-preview-mask {
.result-container:hover .preview-mask {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<video controls width="100%" height="100%">
<source :src="url" :type="htmlVideoType" />
{{ $t('videoFailedToLoad') }}
</video>
</template>
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { computed } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const settingStore = useSettingStore()
const vhsAdvancedPreviews = computed(() =>
settingStore.get('VHS.AdvancedPreviews')
)
const url = computed(() =>
vhsAdvancedPreviews.value
? props.result.vhsAdvancedPreviewUrl
: props.result.url
)
const htmlVideoType = computed(() =>
vhsAdvancedPreviews.value ? 'video/webm' : props.result.htmlVideoType
)
</script>

View File

@@ -83,11 +83,12 @@ const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null = flatOutputs.length
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const node: ComfyNode | null =
flatOutputs.length && props.task.workflow
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const emit = defineEmits<{

View File

@@ -1,3 +1,6 @@
// @ts-strict-ignore
// Disabled because of https://github.com/Comfy-Org/ComfyUI_frontend/issues/1184
import { mount } from '@vue/test-utils'
import { expect, describe, it } from 'vitest'
import ResultGallery from '../ResultGallery.vue'
@@ -12,15 +15,12 @@ describe('ResultGallery', () => {
let mockResultItem: ResultItemImpl
beforeEach(() => {
mockResultItem = {
mockResultItem = new ResultItemImpl({
filename: 'test.jpg',
type: 'images',
nodeId: 'test',
mediaType: 'images',
url: 'https://picsum.photos/200/300',
urlWithTimestamp: 'https://picsum.photos/200/300?t=123456',
supportsPreview: true
}
nodeId: 1,
mediaType: 'images'
})
})
const mountResultGallery = (props: ResultGalleryProps, options = {}) => {

View File

@@ -9,7 +9,12 @@
}"
>
<template #item="{ item, props }">
<a class="p-menubar-item-link" v-bind="props.action">
<a
class="p-menubar-item-link"
v-bind="props.action"
:href="item.url"
target="_blank"
>
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
<span class="p-menubar-item-label">{{ item.label }}</span>
<span

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useToastStore } from '@/stores/toastStore'
import { app } from '../../scripts/app'
import { $el } from '../../scripts/ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
// Allows for simple dynamic prompt replacement

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { mergeIfValid } from './widgetInputs'
@@ -7,6 +8,7 @@ import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyLink, ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { useToastStore } from '@/stores/toastStore'
import { ComfyExtension } from '@/types/comfy'
type GroupNodeWorkflowData = {
external: ComfyLink[]
@@ -52,7 +54,7 @@ class GroupNodeBuilder {
nodes: LGraphNode[]
nodeData: any
constructor(nodes) {
constructor(nodes: LGraphNode[]) {
this.nodes = nodes
}
@@ -995,9 +997,7 @@ export class GroupNodeHandler {
},
{
content: 'Manage Group Node',
callback: () => {
new ManageGroupDialog(app).show(this.type)
}
callback: manageGroupNodes
}
)
}
@@ -1367,11 +1367,11 @@ export class GroupNodeHandler {
return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP]
}
static isGroupNode(node) {
static isGroupNode(node: LGraphNode) {
return !!node.constructor?.nodeData?.[GROUP]
}
static async fromNodes(nodes) {
static async fromNodes(nodes: LGraphNode[]) {
// Process the nodes into the stored workflow group node data
const builder = new GroupNodeBuilder(nodes)
const res = builder.build()
@@ -1404,9 +1404,7 @@ function addConvertToGroupOptions() {
options.splice(index + 1, null, {
content: `Convert to Group Node`,
disabled,
callback: async () => {
return await GroupNodeHandler.fromNodes(selected)
}
callback: convertSelectedNodesToGroupNode
})
}
@@ -1416,9 +1414,7 @@ function addConvertToGroupOptions() {
options.splice(index + 1, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => {
new ManageGroupDialog(app).show()
}
callback: manageGroupNodes
})
}
@@ -1455,10 +1451,86 @@ const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
if (nodes.some((n) => GroupNodeHandler.isGroupNode(n))) {
throw new Error('Selected nodes contain a group node')
}
return await GroupNodeHandler.fromNodes(nodes)
}
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
if (GroupNodeHandler.isGroupNode(node)) {
node['convertToNodes']?.()
}
}
}
function manageGroupNodes() {
new ManageGroupDialog(app).show()
}
const id = 'Comfy.GroupNode'
let globalDefs
const ext = {
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: convertSelectedNodesToGroupNode
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: ungroupSelectedGroupNodes
},
{
id: 'Comfy.GroupNode.ManageGroupNodes',
label: 'Manage group nodes',
icon: 'pi pi-cog',
versionAdded: '1.3.17',
function: manageGroupNodes
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
alt: true,
shift: true,
key: 'G'
}
}
],
setup() {
addConvertToGroupOptions()
},

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el, ComfyDialog } from '../../scripts/ui'
import { DraggableList } from '../../scripts/ui/draggableList'
import { GroupNodeConfig, GroupNodeHandler } from './groupNode'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LGraphGroup } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LiteGraph } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'

View File

@@ -30,7 +30,7 @@ app.registerExtension({
const keybindingStore = useKeybindingStore()
const commandStore = useCommandStore()
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding) {
if (keybinding && keybinding.targetSelector !== '#graph-canvas') {
await commandStore.execute(keybinding.commandId)
event.preventDefault()
return

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { LiteGraph } from '@comfyorg/litegraph'
const id = 'Comfy.LinkRenderMode'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app, type ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { LGraphNode } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { ComfyDialog, $el } from '../../scripts/ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { IContextMenuValue } from '@comfyorg/litegraph'
import { app } from '../../scripts/app'
import { mergeIfValid, getWidgetConfig, setWidgetConfig } from './widgetInputs'
@@ -159,7 +160,6 @@ app.registerExtension({
for (const l of node.outputs[0].links || []) {
const link = app.graph.links[l]
if (link) {
// @ts-expect-error Fix litegraph types
link.color = color
if (app.configuringGraph) continue
@@ -205,7 +205,6 @@ app.registerExtension({
if (inputNode) {
const link = app.graph.links[inputNode.inputs[0].link]
if (link) {
// @ts-expect-error Fix litegraph types
link.color = color
}
}

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { applyTextReplacements } from '../../scripts/utils'
// Use widget values and dates in output filenames

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyWidgets } from '../../scripts/widgets'
import { LiteGraph } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,5 @@
// @ts-strict-ignore
import type { SettingParams } from '@/types/settingTypes'
import { app } from '../../scripts/app'
import {
LGraphCanvas,
@@ -37,13 +39,25 @@ app.registerExtension({
LiteGraph.CANVAS_GRID_SIZE = +value || 10
}
})
// Keep the 'pysssss.SnapToGrid' setting id so we don't need to migrate setting values.
// Using a new setting id can cause existing users to lose their existing settings.
const alwaysSnapToGrid = app.ui.settings.addSetting({
id: 'pysssss.SnapToGrid',
category: ['Comfy', 'Graph', 'AlwaysSnapToGrid'],
name: 'Always snap to grid',
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.13'
} as SettingParams)
const shouldSnapToGrid = () => app.shiftDown || alwaysSnapToGrid.value
// After moving a node, if the shift key is down align it to grid
const onNodeMoved = app.canvas.onNodeMoved
app.canvas.onNodeMoved = function (node) {
const r = onNodeMoved?.apply(this, arguments)
if (app.shiftDown) {
if (shouldSnapToGrid()) {
// Ensure all selected nodes are realigned
for (const id in this.selected_nodes) {
this.selected_nodes[id].alignToGrid()
@@ -58,7 +72,7 @@ app.registerExtension({
app.graph.onNodeAdded = function (node) {
const onResize = node.onResize
node.onResize = function () {
if (app.shiftDown) {
if (shouldSnapToGrid()) {
roundVectorToGrid(node.size)
}
return onResize?.apply(this, arguments)
@@ -70,7 +84,7 @@ app.registerExtension({
const origDrawNode = LGraphCanvas.prototype.drawNode
LGraphCanvas.prototype.drawNode = function (node, ctx) {
if (
app.shiftDown &&
shouldSnapToGrid() &&
this.node_dragged &&
node.id in this.selected_nodes
) {
@@ -132,8 +146,8 @@ app.registerExtension({
// to snap on a mouse-up which we can determine by checking if `app.canvas.last_mouse_dragging`
// has been set to `false`. Essentially, this check here is the equivalent to calling an
// `LGraphGroup.prototype.onNodeMoved` if it had existed.
if (app.canvas.last_mouse_dragging === false && app.shiftDown) {
// After moving a group (while app.shiftDown), snap all the child nodes and, finally,
if (app.canvas.last_mouse_dragging === false && shouldSnapToGrid()) {
// After moving a group (while shouldSnapToGrid()), snap all the child nodes and, finally,
// align the group itself.
this.recomputeInsideNodes()
for (const node of this.nodes) {
@@ -151,7 +165,7 @@ app.registerExtension({
*/
const drawGroups = LGraphCanvas.prototype.drawGroups
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
if (this.selected_group && app.shiftDown) {
if (this.selected_group && shouldSnapToGrid()) {
if (this.selected_group_resizing) {
roundVectorToGrid(this.selected_group.size)
} else if (selectedAndMovingGroup) {
@@ -176,7 +190,7 @@ app.registerExtension({
const onGroupAdd = LGraphCanvas.onGroupAdd
LGraphCanvas.onGroupAdd = function () {
const v = onGroupAdd.apply(app.canvas, arguments)
if (app.shiftDown) {
if (shouldSnapToGrid()) {
const lastGroup = app.graph.groups[app.graph.groups.length - 1]
if (lastGroup) {
roundVectorToGrid(lastGroup.pos)

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import type { IWidget } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { ComfyNodeDef } from '@/types/apiTypes'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../../scripts/app'
import { api } from '../../scripts/api'
import { useToastStore } from '@/stores/toastStore'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ComfyWidgets, addValueControlWidgets } from '../../scripts/widgets'
import { app } from '../../scripts/app'
import { applyTextReplacements } from '../../scripts/utils'

59
src/hooks/dndHooks.ts Normal file
View File

@@ -0,0 +1,59 @@
import { onBeforeUnmount, onMounted } from 'vue'
import {
dropTargetForElements,
draggable
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
export function usePragmaticDroppable(
dropTargetElement: HTMLElement | (() => HTMLElement),
options: Omit<Parameters<typeof dropTargetForElements>[0], 'element'>
) {
let cleanup = () => {}
onMounted(() => {
const element =
typeof dropTargetElement === 'function'
? dropTargetElement()
: dropTargetElement
if (!element) {
return
}
cleanup = dropTargetForElements({
element,
...options
})
})
onBeforeUnmount(() => {
cleanup()
})
}
export function usePragmaticDraggable(
draggableElement: HTMLElement | (() => HTMLElement),
options: Omit<Parameters<typeof draggable>[0], 'element'>
) {
let cleanup = () => {}
onMounted(() => {
const element =
typeof draggableElement === 'function'
? draggableElement()
: draggableElement
if (!element) {
return
}
cleanup = draggable({
element,
...options
})
})
onBeforeUnmount(() => {
cleanup()
})
}

View File

@@ -0,0 +1,16 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'model-library',
icon: 'pi pi-box',
title: t('sideToolbar.modelLibrary'),
tooltip: t('sideToolbar.modelLibrary'),
component: markRaw(ModelLibrarySidebarTab),
type: 'vue'
}
}

View File

@@ -0,0 +1,16 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeLibrarySidebarTab from '@/components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useNodeLibrarySidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
return {
id: 'node-library',
icon: 'pi pi-book',
title: t('sideToolbar.nodeLibrary'),
tooltip: t('sideToolbar.nodeLibrary'),
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
}
}

View File

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

View File

@@ -0,0 +1,30 @@
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useSettingStore } from '@/stores/settingStore'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
return {
id: 'workflows',
icon: 'pi pi-folder-open',
iconBadge: () => {
if (
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
) {
return null
}
const value = workflowStore.openWorkflows.length.toString()
return value === '0' ? null : value
},
title: t('sideToolbar.workflows'),
tooltip: t('sideToolbar.workflows'),
component: markRaw(WorkflowsSidebarTab),
type: 'vue'
}
}

View File

@@ -2,6 +2,7 @@ import { createI18n } from 'vue-i18n'
const messages = {
en: {
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert',
@@ -48,7 +49,6 @@ const messages = {
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
noContent: '(No Content)',
noTasksFound: 'No Tasks Found',
noTasksFoundMessage: 'There are no tasks in the queue.',
newFolder: 'New Folder',
@@ -85,6 +85,7 @@ const messages = {
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow',
queueWorkflowFront: 'Queue workflow (Insert at Front)',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
@@ -111,6 +112,7 @@ const messages = {
}
},
zh: {
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
insert: '插入',
@@ -144,7 +146,7 @@ const messages = {
delete: '删除',
rename: '重命名',
customize: '定制',
experimental: '测试',
experimental: 'BETA',
deprecated: '弃用',
loadWorkflow: '加载工作流',
goToNode: '前往节点',
@@ -192,6 +194,7 @@ const messages = {
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行工作流',
queueWorkflowFront: '执行工作流 (队列首)',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
@@ -216,6 +219,119 @@ const messages = {
panMode: '平移模式',
toggleLinkVisibility: '切换链接可见性'
}
},
ru: {
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: {
batchCount: 'Количество пакетов',
batchCountTooltip:
'Количество раз, когда генерация рабочего процесса должна быть помещена в очередь',
autoQueue: 'Автоочередь',
disabled: 'Отключено',
disabledTooltip:
'Рабочий процесс не будет автоматически помещён в очередь',
instant: 'Мгновенно',
instantTooltip:
'Рабочий процесс будет помещён в очередь сразу же после завершения генерации',
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса',
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: 'Сбросить вид',
selectMode: 'Выбрать режим',
panMode: 'Режим панорамирования',
toggleLinkVisibility: 'Переключить видимость ссылок'
}
}
// TODO: Add more languages
}

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import App from './App.vue'
import router from '@/router'
import { createApp } from 'vue'
@@ -9,7 +10,6 @@ import Aura from '@primevue/themes/aura'
import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import 'reflect-metadata'
import '@comfyorg/litegraph/style.css'
import '@/assets/css/style.css'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
DownloadModelStatus,
@@ -273,9 +274,15 @@ class ComfyApi extends EventTarget {
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
async getNodeDefs({ validate = false }: { validate?: boolean } = {}): Promise<
Record<string, ComfyNodeDef>
> {
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
const objectInfoUnsafe = await resp.json()
if (!validate) {
return objectInfoUnsafe
}
// Validate node definitions against zod schema. (slow)
const objectInfo: Record<string, ComfyNodeDef> = {}
for (const key in objectInfoUnsafe) {
const validatedDef = validateComfyNodeDef(

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ComfyLogging } from './logging'
import { ComfyWidgetConstructor, ComfyWidgets, initWidgets } from './widgets'
import { ComfyUI, $el } from './ui'
@@ -38,7 +39,7 @@ import {
SYSTEM_NODE_DEFS,
useNodeDefStore
} from '@/stores/nodeDefStore'
import { Vector2 } from '@comfyorg/litegraph'
import { INodeInputSlot, Vector2 } from '@comfyorg/litegraph'
import _ from 'lodash'
import {
showExecutionErrorDialog,
@@ -53,6 +54,9 @@ import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useExecutionStore } from '@/stores/executionStore'
import { IWidget } from '@comfyorg/litegraph'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { shallowReactive } from 'vue'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -111,7 +115,6 @@ export class ComfyApp {
extensionManager: ExtensionManager
_nodeOutputs: Record<string, any>
nodePreviewImages: Record<string, typeof Image>
shiftDown: boolean
graph: LGraph
enableWorkflowViewRestore: any
canvas: LGraphCanvas
@@ -137,12 +140,20 @@ export class ComfyApp {
menu: ComfyAppMenu
bypassBgColor: string
// @deprecated
// Use useExecutionStore().executingNodeId instead
/**
* @deprecated Use useExecutionStore().executingNodeId instead
*/
get runningNodeId(): string | null {
return useExecutionStore().executingNodeId
}
/**
* @deprecated Use useWorkspaceStore().shiftDown instead
*/
get shiftDown(): boolean {
return useWorkspaceStore().shiftDown
}
constructor() {
this.vueAppReady = false
this.ui = new ComfyUI(this)
@@ -175,12 +186,6 @@ export class ComfyApp {
* @type {Record<string, Image>}
*/
this.nodePreviewImages = {}
/**
* If the shift key on the keyboard is pressed
* @type {boolean}
*/
this.shiftDown = false
}
get nodeOutputs() {
@@ -1278,13 +1283,10 @@ export class ComfyApp {
/**
* Handle keypress
*
* Ctrl + M mute/unmute selected nodes
*/
#addProcessKeyHandler() {
const self = this
const origProcessKey = LGraphCanvas.prototype.processKey
LGraphCanvas.prototype.processKey = function (e) {
LGraphCanvas.prototype.processKey = function (e: KeyboardEvent) {
if (!this.graph) {
return
}
@@ -1296,54 +1298,11 @@ export class ComfyApp {
}
if (e.type == 'keydown' && !e.repeat) {
// Ctrl + M mute/unmute
if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 2) {
// never
this.selected_nodes[i].mode = 0 // always
} else {
this.selected_nodes[i].mode = 2 // never
}
}
}
block_default = true
}
// Ctrl + B bypass
if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 4) {
// never
this.selected_nodes[i].mode = 0 // always
} else {
this.selected_nodes[i].mode = 4 // never
}
}
}
block_default = true
}
// p pin/unpin
if (e.key === 'p') {
if (this.selected_nodes) {
for (const i in this.selected_nodes) {
const node = this.selected_nodes[i]
node.pin()
}
}
block_default = true
}
// Alt + C collapse/uncollapse
if (e.key === 'c' && e.altKey) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
this.selected_nodes[i].collapse()
}
}
const keyCombo = KeyComboImpl.fromEvent(e)
const keybindingStore = useKeybindingStore()
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding && keybinding.targetSelector === '#graph-canvas') {
useCommandStore().execute(keybinding.commandId)
block_default = true
}
@@ -1362,26 +1321,6 @@ export class ComfyApp {
// Trigger onPaste
return true
}
if (e.key === '+' && e.altKey) {
block_default = true
let scale = this.ds.scale * 1.1
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2
])
this.graph.change()
}
if (e.key === '-' && e.altKey) {
block_default = true
let scale = (this.ds.scale * 1) / 1.1
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2
])
this.graph.change()
}
}
this.graph.change()
@@ -1681,15 +1620,6 @@ export class ComfyApp {
api.init()
}
#addKeyboardHandler() {
window.addEventListener('keydown', (e) => {
this.shiftDown = e.shiftKey
})
window.addEventListener('keyup', (e) => {
this.shiftDown = e.shiftKey
})
}
#addConfigureHandler() {
const app = this
const configure = LGraph.prototype.configure
@@ -1908,7 +1838,20 @@ export class ComfyApp {
this.#addAfterConfigureHandler()
this.canvas = new LGraphCanvas(canvasEl, this.graph)
// Make LGraphCanvas shallow reactive so that any change on the root object
// triggers reactivity.
this.canvas = shallowReactive(
new LGraphCanvas(canvasEl, this.graph, {
skip_events: true,
skip_render: true
})
)
// Bind event/ start rendering later, so that event handlers get reactive canvas reference.
this.canvas.options.skip_events = false
this.canvas.options.skip_render = false
this.canvas.bindEvents()
this.canvas.startRendering()
this.ctx = canvasEl.getContext('2d')
LiteGraph.alt_drag_do_clone_nodes = true
@@ -1970,7 +1913,6 @@ export class ComfyApp {
this.#addDropHandler()
this.#addCopyHandler()
this.#addPasteHandler()
this.#addKeyboardHandler()
this.#addWidgetLinkHandling()
await this.#invokeExtensionsAsync('setup')
@@ -2027,7 +1969,9 @@ export class ComfyApp {
*/
async registerNodes() {
// Load node definitions from the backend
const defs = await api.getNodeDefs()
const defs = await api.getNodeDefs({
validate: useSettingStore().get('Comfy.Validation.NodeDefs')
})
await this.registerNodesFromDefs(defs)
await this.#invokeExtensionsAsync('registerCustomNodes')
if (this.vueAppReady) {
@@ -2150,6 +2094,11 @@ export class ComfyApp {
incoming: Record<string, any>
) => {
const result = { ...incoming }
if (current.widget === undefined && incoming.widget !== undefined) {
// Field must be input as only inputs can be converted
this.inputs.push(current as INodeInputSlot)
return incoming
}
for (const key of ['name', 'type', 'shape']) {
if (current[key] !== undefined) {
result[key] = current[key]
@@ -2231,7 +2180,7 @@ export class ComfyApp {
localStorage.setItem('litegrapheditor_clipboard', old)
}
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
#showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
showLoadWorkflowWarning({
missingNodeTypes,
@@ -2244,7 +2193,7 @@ export class ComfyApp {
})
}
showMissingModelsError(missingModels, paths) {
#showMissingModelsError(missingModels, paths) {
if (useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
showMissingModelsWarning({
missingModels,
@@ -2483,11 +2432,11 @@ export class ComfyApp {
// TODO: Properly handle if both nodes and models are missing (sequential dialogs?)
if (missingNodeTypes.length && showMissingNodesDialog) {
this.showMissingNodesError(missingNodeTypes)
this.#showMissingNodesError(missingNodeTypes)
}
if (missingModels.length && showMissingModelsDialog) {
const paths = await api.getFolderPaths()
this.showMissingModelsError(missingModels, paths)
this.#showMissingModelsError(missingModels, paths)
}
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
requestAnimationFrame(() => {
@@ -2874,7 +2823,7 @@ export class ComfyApp {
(n) => !LiteGraph.registered_node_types[n.class_type]
)
if (missingNodeTypes.length) {
this.showMissingNodesError(
this.#showMissingNodesError(
// @ts-expect-error
missingNodeTypes.map((t) => t.class_type),
false
@@ -2989,7 +2938,9 @@ export class ComfyApp {
useModelStore().clearCache()
}
const defs = await api.getNodeDefs()
const defs = await api.getNodeDefs({
validate: useSettingStore().get('Comfy.Validation.NodeDefs')
})
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId])

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
@@ -131,7 +132,11 @@ export class ChangeTracker {
let keyIgnored = false
window.addEventListener(
'keydown',
(e) => {
(e: KeyboardEvent) => {
// Do not trigger on repeat events (Holding down a key)
// This can happen when user is holding down "Space" to pan the canvas.
if (e.repeat) return
const activeEl = document.activeElement
requestAnimationFrame(async () => {
let bindInputEl

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app, ANIM_PREVIEW_WIDGET } from './app'
import { LGraphCanvas, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type { Vector4 } from '@comfyorg/litegraph'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el, ComfyDialog } from './ui'
import { api } from './api'
import type { ComfyApp } from './app'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
export function getFromFlacBuffer(buffer: ArrayBuffer): Record<string, string> {
const dataView = new DataView(buffer)

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
export function getFromPngBuffer(buffer: ArrayBuffer) {
// Get the PNG data as a Uint8Array
const pngData = new Uint8Array(buffer)

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromPngFile } from './metadata/png'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { api } from './api'
import { ComfyDialog as _ComfyDialog } from './ui/dialog'
import { toggleSwitch } from './ui/toggleSwitch'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ComfyDialog } from '../dialog'
import { $el } from '../../ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el } from '../../ui'
import { applyClasses, ClassList, toggleElement } from '../utils'
import { prop } from '../../utils'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el } from '../../ui'
import { ComfyButton } from './button'
import { prop } from '../../utils'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { prop } from '../../utils'
import { $el } from '../../ui'
import { applyClasses, ClassList } from '../utils'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { useDialogStore } from '@/stores/dialogStore'
import { $el } from '../ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
/*
Original implementation:
https://github.com/TahaSh/drag-to-reorder

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '../app'
import { $el } from '../ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el } from '../ui'
import { api } from '../api'
import { ComfyDialog } from './dialog'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { $el } from '../ui'
/**

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { api } from '../api'
import { $el } from '../ui'
import { createSpinner } from './spinner'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
export type ClassList = string | string[] | Record<string, boolean>
export function applyClasses(

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { api } from './api'
import type { ComfyApp } from './app'
import { $el } from './ui'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { api } from './api'
import './domWidget'
import type { ComfyApp } from './app'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import Fuse, { IFuseOptions, FuseSearchOptions } from 'fuse.js'
import _ from 'lodash'

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { defineStore } from 'pinia'
@@ -17,6 +18,7 @@ import { useTitleEditorStore } from './graphStore'
import { useErrorHandling } from '@/hooks/errorHooks'
import { useWorkflowStore } from './workflowStore'
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
import { LGraphNode } from '@comfyorg/litegraph'
export interface ComfyCommand {
id: string
@@ -75,6 +77,28 @@ export class ComfyCommandImpl implements ComfyCommand {
const getTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
const getSelectedNodes = (): LGraphNode[] => {
const selectedNodes = app.canvas.selected_nodes
const result: LGraphNode[] = []
if (selectedNodes) {
for (const i in selectedNodes) {
const node = selectedNodes[i]
result.push(node)
}
}
return result
}
const toggleSelectedNodesMode = (mode: number) => {
getSelectedNodes().forEach((node) => {
if (node.mode === mode) {
node.mode = 0 // always
} else {
node.mode = mode
}
})
}
export const useCommandStore = defineStore('command', () => {
const settingStore = useSettingStore()
@@ -248,7 +272,11 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-plus',
label: 'Zoom In',
function: () => {
app.canvas.ds.changeScale(app.canvas.ds.scale + 0.1)
const ds = app.canvas.ds
ds.changeScale(ds.scale * 1.1, [
ds.element.width / 2,
ds.element.height / 2
])
app.canvas.setDirty(true, true)
}
},
@@ -257,7 +285,11 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-minus',
label: 'Zoom Out',
function: () => {
app.canvas.ds.changeScale(app.canvas.ds.scale - 0.1)
const ds = app.canvas.ds
ds.changeScale(ds.scale / 1.1, [
ds.element.width / 2,
ds.element.height / 2
])
app.canvas.setDirty(true, true)
}
},
@@ -365,6 +397,67 @@ export const useCommandStore = defineStore('command', () => {
function: () => {
useWorkflowStore().loadPreviousOpenedWorkflow()
}
},
{
id: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
icon: 'pi pi-volume-off',
label: 'Mute/Unmute Selected Nodes',
versionAdded: '1.3.11',
function: () => {
toggleSelectedNodesMode(2) // muted
}
},
{
id: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
icon: 'pi pi-shield',
label: 'Bypass/Unbypass Selected Nodes',
versionAdded: '1.3.11',
function: () => {
toggleSelectedNodesMode(4) // bypassed
}
},
{
id: 'Comfy.Canvas.ToggleSelectedNodes.Pin',
icon: 'pi pi-pin',
label: 'Pin/Unpin Selected Nodes',
versionAdded: '1.3.11',
function: () => {
getSelectedNodes().forEach((node) => {
node.pin(!node.pinned)
})
}
},
{
id: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
icon: 'pi pi-minus',
label: 'Collapse/Expand Selected Nodes',
versionAdded: '1.3.11',
function: () => {
getSelectedNodes().forEach((node) => {
node.collapse()
})
}
},
{
id: 'Comfy.ToggleTheme',
icon: 'pi pi-moon',
label: 'Toggle Theme',
versionAdded: '1.3.12',
function: (() => {
let previousDarkTheme: string = 'dark'
// Official light theme is the only light theme supported now.
const isDarkMode = (themeId: string) => themeId !== 'light'
return () => {
const currentTheme = settingStore.get('Comfy.ColorPalette')
if (isDarkMode(currentTheme)) {
previousDarkTheme = currentTheme
settingStore.set('Comfy.ColorPalette', 'light')
} else {
settingStore.set('Comfy.ColorPalette', previousDarkTheme)
}
}
})()
}
]

View File

@@ -94,5 +94,71 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
ctrl: true
},
commandId: 'Comfy.ShowSettingsDialog'
},
// For '=' both holding shift and not holding shift
{
combo: {
key: '=',
alt: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetSelector: '#graph-canvas'
},
{
combo: {
key: '+',
alt: true,
shift: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetSelector: '#graph-canvas'
},
// For number pad '+'
{
combo: {
key: '+',
alt: true
},
commandId: 'Comfy.Canvas.ZoomIn',
targetSelector: '#graph-canvas'
},
{
combo: {
key: '-',
alt: true
},
commandId: 'Comfy.Canvas.ZoomOut',
targetSelector: '#graph-canvas'
},
{
combo: {
key: 'p'
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Pin',
targetSelector: '#graph-canvas'
},
{
combo: {
key: 'c',
alt: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
targetSelector: '#graph-canvas'
},
{
combo: {
key: 'b',
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
targetSelector: '#graph-canvas'
},
{
combo: {
key: 'm',
ctrl: true
},
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
targetSelector: '#graph-canvas'
}
]

View File

@@ -263,7 +263,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Locale',
name: 'Locale',
type: 'combo',
options: ['en', 'zh'],
options: ['en', 'zh', 'ru'],
defaultValue: navigator.language.split('-')[0] || 'en'
},
{
@@ -435,5 +435,14 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
experimental: true,
versionAdded: '1.3.11'
},
{
id: 'Comfy.Validation.NodeDefs',
name: 'Validate node definitions (slow)',
type: 'boolean',
tooltip:
'Recommended for node developers. This will validate all node definitions on startup.',
defaultValue: false,
versionAdded: '1.3.14'
}
]

View File

@@ -40,8 +40,10 @@ export const useDialogStore = defineStore('dialog', {
}) {
this.isVisible = true
nextTick(() => {
this.title = options.title
this.headerComponent = markRaw(options.headerComponent)
this.title = options.title ?? ''
this.headerComponent = options.headerComponent
? markRaw(options.headerComponent)
: null
this.component = markRaw(options.component)
this.props = options.props || {}
this.dialogComponentProps = options.dialogComponentProps || {}

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { api } from '../scripts/api'

View File

@@ -62,6 +62,11 @@ export const useExtensionStore = defineStore('extension', () => {
// pysssss.Locking is replaced by pin/unpin in ComfyUI core.
// https://github.com/Comfy-Org/litegraph.js/pull/117
disabledExtensionNames.value.add('pysssss.Locking')
// pysssss.SnapToGrid is replaced by Comfy.Graph.AlwaysSnapToGrid in ComfyUI core.
// pysssss.SnapToGrid tries to write global app.shiftDown state, which is no longer
// allowed since v1.3.12.
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/1176
disabledExtensionNames.value.add('pysssss.SnapToGrid')
}
// Some core extensions are registered before the store is initialized, e.g.

View File

@@ -1,6 +1,6 @@
import { LGraphNode, LGraphGroup, LGraphCanvas } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { shallowRef } from 'vue'
export const useTitleEditorStore = defineStore('titleEditor', () => {
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
@@ -11,31 +11,14 @@ export const useTitleEditorStore = defineStore('titleEditor', () => {
})
export const useCanvasStore = defineStore('canvas', () => {
/**
* The LGraphCanvas instance.
*
* The root LGraphCanvas object is shallow reactive.
*/
const canvas = shallowRef<LGraphCanvas | null>(null)
const readOnly = ref(false)
const draggingCanvas = ref(false)
document.addEventListener(
'litegraph:canvas',
(e: CustomEvent<{ subType: string; readOnly: boolean }>) => {
if (e.detail?.subType === 'read-only') {
readOnly.value = e.detail.readOnly
}
}
)
document.addEventListener(
'litegraph:canvas',
(e: CustomEvent<{ subType: string; draggingCanvas: boolean }>) => {
if (e.detail?.subType === 'dragging-canvas') {
draggingCanvas.value = e.detail.draggingCanvas
}
}
)
return {
canvas,
readOnly,
draggingCanvas
canvas
}
})

View File

@@ -8,16 +8,20 @@ import type { ComfyExtension } from '@/types/comfy'
export class KeybindingImpl implements Keybinding {
commandId: string
combo: KeyComboImpl
targetSelector?: string
constructor(obj: Keybinding) {
this.commandId = obj.commandId
this.combo = new KeyComboImpl(obj.combo)
this.targetSelector = obj.targetSelector
}
equals(other: any): boolean {
if (toRaw(other) instanceof KeybindingImpl) {
return (
this.commandId === other.commandId && this.combo.equals(other.combo)
this.commandId === other.commandId &&
this.combo.equals(other.combo) &&
this.targetSelector === other.targetSelector
)
}
return false

View File

@@ -98,6 +98,27 @@ export const useMenuItemStore = defineStore('menuItem', () => {
registerCommands(['Edit'], ['Comfy.ClearWorkflow'])
registerCommands(['Edit'], ['Comfy.OpenClipspace'])
registerMenuGroup(
['Help'],
[
{
icon: 'pi pi-github',
label: 'ComfyUI Issues',
url: 'https://github.com/comfyanonymous/ComfyUI/issues'
},
{
icon: 'pi pi-info-circle',
label: 'ComfyUI Docs',
url: 'https://docs.comfy.org/'
},
{
icon: 'pi pi-discord',
label: 'Comfy-Org',
url: 'https://www.comfy.org/discord'
}
]
)
return {
menuItems,
registerMenuGroup,

View File

@@ -19,13 +19,17 @@ function _findInMetadata(metadata: any, ...keys: string[]): string | null {
/** Defines and holds metadata for a model */
export class ComfyModelDef {
/** Proper filename of the model */
file_name: string = ''
readonly file_name: string
/** Normalized filename of the model, with all backslashes replaced with forward slashes */
readonly normalized_file_name: string
/** Directory containing the model, eg 'checkpoints' */
directory: string = ''
readonly directory: string
/** Simplified copy of name, used as a default title. Excludes the directory and the '.safetensors' file extension */
simplified_file_name: string = ''
readonly simplified_file_name: string
/** Key for the model, used to uniquely identify the model. */
readonly key: string
/** Title / display name of the model, sometimes same as the name but not always */
title: string = ''
title: string
/** Metadata: architecture ID for the model, such as 'stable-diffusion-xl-v1-base' */
architecture_id: string = ''
/** Metadata: author of the model */
@@ -48,10 +52,13 @@ export class ComfyModelDef {
is_load_requested: boolean = false
/** If true, this is a fake model object used as a placeholder for something (eg a loading icon) */
is_fake_object: boolean = false
/** A string full of auto-computed lowercase-only searchable text for this model */
searchable: string = ''
constructor(name: string, directory: string) {
this.file_name = name
this.simplified_file_name = name.replaceAll('\\', '/').split('/').pop()
this.normalized_file_name = name.replaceAll('\\', '/')
this.simplified_file_name = this.normalized_file_name.split('/').pop() ?? ''
if (this.simplified_file_name.endsWith('.safetensors')) {
this.simplified_file_name = this.simplified_file_name.slice(
0,
@@ -60,6 +67,21 @@ export class ComfyModelDef {
}
this.title = this.simplified_file_name
this.directory = directory
this.key = `${directory}/${this.normalized_file_name}`
this.updateSearchable()
}
updateSearchable() {
this.searchable = [
this.file_name,
this.title,
this.author,
this.description,
this.trigger_phrase,
this.tags.join(', ')
]
.join('\n')
.toLowerCase()
}
/** Loads the model metadata from the server, filling in this object if data is available */
@@ -110,6 +132,7 @@ export class ComfyModelDef {
_findInMetadata(metadata, 'modelspec.tags', 'tags') || ''
this.tags = tagsCommaSeparated.split(',').map((tag) => tag.trim())
this.has_loaded_metadata = true
this.updateSearchable()
} catch (error) {
console.error('Error loading model metadata', this.file_name, this, error)
}
@@ -138,12 +161,12 @@ const folderBlacklist = ['configs', 'custom_nodes']
/** Model store handler, wraps individual per-folder model stores */
export const useModelStore = defineStore('modelStore', {
state: () => ({
modelStoreMap: {} as Record<string, ModelStore>,
isLoading: {} as Record<string, Promise<ModelStore>>,
modelStoreMap: {} as Record<string, ModelStore | null>,
isLoading: {} as Record<string, Promise<ModelStore | null> | null>,
modelFolders: [] as string[]
}),
actions: {
async getModelsInFolderCached(folder: string): Promise<ModelStore> {
async getModelsInFolderCached(folder: string): Promise<ModelStore | null> {
if (folder in this.modelStoreMap) {
return this.modelStoreMap[folder]
}
@@ -156,7 +179,7 @@ export const useModelStore = defineStore('modelStore', {
}
const store = new ModelStore(folder, models)
this.modelStoreMap[folder] = store
this.isLoading[folder] = false
this.isLoading[folder] = null
return store
})
this.isLoading[folder] = promise

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useSettingStore } from './settingStore'
@@ -15,21 +16,24 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
const nodeDefStore = useNodeDefStore()
const migrateLegacyBookmarks = () => {
settingStore
.get('Comfy.NodeLibrary.Bookmarks')
.forEach((bookmark: string) => {
// If the bookmark is a folder, add it as a bookmark
if (bookmark.endsWith('/')) {
addBookmark(bookmark)
return
}
const category = bookmark.split('/').slice(0, -1).join('/')
const displayName = bookmark.split('/').pop()
const nodeDef = nodeDefStore.nodeDefsByDisplayName[displayName]
const legacyBookmarks = settingStore.get('Comfy.NodeLibrary.Bookmarks')
if (!legacyBookmarks.length) {
return
}
if (!nodeDef) return
addBookmark(`${category === '' ? '' : category + '/'}${nodeDef.name}`)
})
legacyBookmarks.forEach((bookmark: string) => {
// If the bookmark is a folder, add it as a bookmark
if (bookmark.endsWith('/')) {
addBookmark(bookmark)
return
}
const category = bookmark.split('/').slice(0, -1).join('/')
const displayName = bookmark.split('/').pop()
const nodeDef = nodeDefStore.nodeDefsByDisplayName[displayName]
if (!nodeDef) return
addBookmark(`${category === '' ? '' : category + '/'}${nodeDef.name}`)
})
settingStore.set('Comfy.NodeLibrary.Bookmarks', [])
}

View File

@@ -1,10 +1,13 @@
// @ts-strict-ignore
import {
NodeSearchService,
type SearchAuxScore
} from '@/services/nodeSearchService'
import { ComfyNodeDef } from '@/types/apiTypes'
import {
type ComfyNodeDef,
type ComfyInputsSpec as ComfyInputsSpecSchema
} from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { Type, Transform, plainToClass, Expose } from 'class-transformer'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
import { TreeNode } from 'primevue/treenode'
import { buildTree } from '@/utils/treeUtil'
@@ -12,87 +15,60 @@ import { computed, ref } from 'vue'
import axios from 'axios'
import { type NodeSource, getNodeSource } from '@/types/nodeSource'
export class BaseInputSpec<T = any> {
export interface BaseInputSpec<T = any> {
name: string
type: string
tooltip?: string
default?: T
@Type(() => Boolean)
forceInput?: boolean
static isInputSpec(obj: any): boolean {
return (
Array.isArray(obj) &&
obj.length >= 1 &&
(typeof obj[0] === 'string' || Array.isArray(obj[0]))
)
}
}
export class NumericInputSpec extends BaseInputSpec<number> {
@Type(() => Number)
export interface NumericInputSpec extends BaseInputSpec<number> {
min?: number
@Type(() => Number)
max?: number
@Type(() => Number)
step?: number
}
export class IntInputSpec extends NumericInputSpec {
type: 'INT' = 'INT'
export interface IntInputSpec extends NumericInputSpec {
type: 'INT'
}
export class FloatInputSpec extends NumericInputSpec {
type: 'FLOAT' = 'FLOAT'
@Type(() => Number)
export interface FloatInputSpec extends NumericInputSpec {
type: 'FLOAT'
round?: number
}
export class BooleanInputSpec extends BaseInputSpec<boolean> {
type: 'BOOLEAN' = 'BOOLEAN'
export interface BooleanInputSpec extends BaseInputSpec<boolean> {
type: 'BOOLEAN'
labelOn?: string
labelOff?: string
}
export class StringInputSpec extends BaseInputSpec<string> {
type: 'STRING' = 'STRING'
@Type(() => Boolean)
export interface StringInputSpec extends BaseInputSpec<string> {
type: 'STRING'
multiline?: boolean
@Type(() => Boolean)
dynamicPrompts?: boolean
}
export class ComboInputSpec extends BaseInputSpec<any> {
type: string = 'COMBO'
@Transform(({ value }) => value[0])
export interface ComboInputSpec extends BaseInputSpec<any> {
type: 'COMBO'
comboOptions: any[]
@Type(() => Boolean)
controlAfterGenerate?: boolean
@Type(() => Boolean)
imageUpload?: boolean
}
export class CustomInputSpec extends BaseInputSpec {}
export class ComfyInputsSpec {
@Transform(({ value }) => ComfyInputsSpec.transformInputSpecRecord(value))
required: Record<string, BaseInputSpec> = {}
@Transform(({ value }) => ComfyInputsSpec.transformInputSpecRecord(value))
optional: Record<string, BaseInputSpec> = {}
required: Record<string, BaseInputSpec>
optional: Record<string, BaseInputSpec>
hidden?: Record<string, any>
constructor(obj: ComfyInputsSpecSchema) {
this.required = ComfyInputsSpec.transformInputSpecRecord(obj.required) ?? {}
this.optional = ComfyInputsSpec.transformInputSpecRecord(obj.optional) ?? {}
this.hidden = obj.hidden
}
private static transformInputSpecRecord(
record: Record<string, any>
): Record<string, BaseInputSpec> {
@@ -104,35 +80,39 @@ export class ComfyInputsSpec {
return result
}
private static isInputSpec(obj: any): boolean {
return (
Array.isArray(obj) &&
obj.length >= 1 &&
(typeof obj[0] === 'string' || Array.isArray(obj[0]))
)
}
private static transformSingleInputSpec(
name: string,
value: any
): BaseInputSpec {
if (!BaseInputSpec.isInputSpec(value)) return value
if (!ComfyInputsSpec.isInputSpec(value)) return value
const [typeRaw, _spec] = value
const spec = _spec ?? {}
const type = Array.isArray(typeRaw) ? 'COMBO' : value[0]
switch (type) {
case 'INT':
return plainToClass(IntInputSpec, { name, type, ...spec })
case 'FLOAT':
return plainToClass(FloatInputSpec, { name, type, ...spec })
case 'BOOLEAN':
return plainToClass(BooleanInputSpec, { name, type, ...spec })
case 'STRING':
return plainToClass(StringInputSpec, { name, type, ...spec })
case 'COMBO':
return plainToClass(ComboInputSpec, {
return {
name,
type,
...spec,
comboOptions: typeRaw,
default: spec.default ?? typeRaw[0]
})
} as ComboInputSpec
case 'INT':
case 'FLOAT':
case 'BOOLEAN':
case 'STRING':
default:
return plainToClass(CustomInputSpec, { name, type, ...spec })
return { name, type, ...spec } as BaseInputSpec
}
}
@@ -165,52 +145,46 @@ export class ComfyOutputsSpec {
}
}
/**
* Note: This class does not implement the ComfyNodeDef interface, as we are
* using a custom output spec for output definitions.
*/
export class ComfyNodeDefImpl {
name: string
display_name: string
category: string
python_module: string
description: string
@Transform(({ value, obj }) => value ?? obj.category === '', {
toClassOnly: true
})
@Type(() => Boolean)
@Expose()
deprecated: boolean
@Transform(
({ value, obj }) => value ?? obj.category.startsWith('_for_testing'),
{
toClassOnly: true
}
)
@Type(() => Boolean)
@Expose()
experimental: boolean
@Type(() => ComfyInputsSpec)
input: ComfyInputsSpec
@Transform(({ obj }) => ComfyNodeDefImpl.transformOutputSpec(obj))
output: ComfyOutputsSpec
@Transform(({ obj }) => getNodeSource(obj.python_module), {
toClassOnly: true
})
@Expose()
nodeSource: NodeSource
constructor(obj: ComfyNodeDef) {
this.name = obj.name
this.display_name = obj.display_name
this.category = obj.category
this.python_module = obj.python_module
this.description = obj.description
this.deprecated = obj.deprecated ?? obj.category === ''
this.experimental =
obj.experimental ?? obj.category.startsWith('_for_testing')
this.input = new ComfyInputsSpec(obj.input ?? {})
this.output = ComfyNodeDefImpl.transformOutputSpec(obj)
this.nodeSource = getNodeSource(obj.python_module)
}
private static transformOutputSpec(obj: any): ComfyOutputsSpec {
const { output, output_is_list, output_name, output_tooltips } = obj
const result = output.map((type: string | any[], index: number) => {
const result = (output ?? []).map((type: string | any[], index: number) => {
const typeString = Array.isArray(type) ? 'COMBO' : type
return new ComfyOutputSpec(
index,
output_name[index],
output_name?.[index],
typeString,
output_is_list[index],
output_is_list?.[index],
Array.isArray(type) ? type : undefined,
output_tooltips?.[index]
)
@@ -276,13 +250,17 @@ export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
}
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
return plainToClass(ComfyNodeDefImpl, {
return new ComfyNodeDefImpl({
name: '',
display_name: '',
category: folderPath.endsWith('/') ? folderPath.slice(0, -1) : folderPath,
python_module: 'nodes',
description: 'Dummy Folder Node (User should never see this string)'
})
description: 'Dummy Folder Node (User should never see this string)',
input: {},
output: [],
output_name: [],
output_is_list: []
} as ComfyNodeDef)
}
interface State {
@@ -325,7 +303,7 @@ export const useNodeDefStore = defineStore('nodeDef', {
const newNodeDefsByName: { [key: string]: ComfyNodeDefImpl } = {}
const nodeDefsByDisplayName: { [key: string]: ComfyNodeDefImpl } = {}
for (const nodeDef of nodeDefs) {
const nodeDefImpl = plainToClass(ComfyNodeDefImpl, nodeDef)
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
newNodeDefsByName[nodeDef.name] = nodeDefImpl
nodeDefsByDisplayName[nodeDef.display_name] = nodeDefImpl
}
@@ -333,7 +311,7 @@ export const useNodeDefStore = defineStore('nodeDef', {
this.nodeDefsByDisplayName = nodeDefsByDisplayName
},
addNodeDef(nodeDef: ComfyNodeDef) {
const nodeDefImpl = plainToClass(ComfyNodeDefImpl, nodeDef)
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
this.nodeDefsByName[nodeDef.name] = nodeDefImpl
this.nodeDefsByDisplayName[nodeDef.display_name] = nodeDefImpl
},

View File

@@ -1,5 +1,6 @@
// @ts-strict-ignore
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import type {
TaskItem,
TaskType,
@@ -9,8 +10,7 @@ import type {
TaskOutput,
ResultItem
} from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow'
import { plainToClass } from 'class-transformer'
import type { ComfyWorkflowJSON, NodeId } from '@/types/comfyWorkflow'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
@@ -35,17 +35,92 @@ export class ResultItemImpl {
// 'audio' | 'images' | ...
mediaType: string
// VHS output specific fields
format?: string
frame_rate?: number
constructor(obj: Record<string, any>) {
this.filename = obj.filename
this.subfolder = obj.subfolder
this.type = obj.type
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
this.format = obj.format
this.frame_rate = obj.frame_rate
}
private get urlParams(): URLSearchParams {
const params = new URLSearchParams()
params.set('filename', this.filename)
params.set('type', this.type)
params.set('subfolder', this.subfolder || '')
if (this.format) {
params.set('format', this.format)
}
if (this.frame_rate) {
params.set('frame_rate', this.frame_rate.toString())
}
return params
}
/**
* VHS advanced preview URL. `/viewvideo` endpoint is provided by VHS node.
*
* `/viewvideo` always returns a webm file.
*/
get vhsAdvancedPreviewUrl(): string {
return api.apiURL('/viewvideo?' + this.urlParams)
}
get url(): string {
return api.apiURL(`/view?filename=${encodeURIComponent(this.filename)}&type=${this.type}&
subfolder=${encodeURIComponent(this.subfolder || '')}`)
return api.apiURL('/view?' + this.urlParams)
}
get urlWithTimestamp(): string {
return `${this.url}&t=${+new Date()}`
}
get isVhsFormat(): boolean {
return !!this.format && !!this.frame_rate
}
get htmlVideoType(): string | undefined {
const defaultType = undefined
if (!this.isVhsFormat) {
return defaultType
}
if (this.format.endsWith('webm')) {
return 'video/webm'
}
if (this.format.endsWith('mp4')) {
return 'video/mp4'
}
return defaultType
}
get isVideo(): boolean {
return !this.isImage && this.format && this.format.startsWith('video/')
}
get isGif(): boolean {
return this.filename.endsWith('.gif')
}
get isWebp(): boolean {
return this.filename.endsWith('.webp')
}
get isImage(): boolean {
return this.mediaType === 'images' || this.isGif || this.isWebp
}
get supportsPreview(): boolean {
return ['images', 'gifs'].includes(this.mediaType)
return this.isImage || this.isVideo
}
}
@@ -76,12 +151,13 @@ export class TaskItemImpl {
}
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map((item: ResultItem) =>
plainToClass(ResultItemImpl, {
...item,
nodeId,
mediaType
})
(items as ResultItem[]).map(
(item: ResultItem) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
)
)
@@ -138,8 +214,8 @@ export class TaskItemImpl {
return this.extraData.client_id
}
get workflow() {
return this.extraPngInfo.workflow
get workflow(): ComfyWorkflowJSON | undefined {
return this.extraPngInfo?.workflow
}
get messages() {
@@ -213,7 +289,10 @@ export class TaskItemImpl {
: undefined
}
public async loadWorkflow() {
public async loadWorkflow(app: ComfyApp) {
if (!this.workflow) {
return
}
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
app.nodeOutputs = toRaw(this.outputs)

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
/**
* TODO: Migrate scripts/ui/settings.ts here
*

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ComfyWorkflow } from '@/scripts/workflows'

View File

@@ -1,6 +1,11 @@
import { useModelLibrarySidebarTab } from '@/hooks/sidebarTabs/modelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/hooks/sidebarTabs/nodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/hooks/sidebarTabs/queueSidebarTab'
import { useWorkflowsSidebarTab } from '@/hooks/sidebarTabs/workflowsSidebarTab'
import { SidebarTabExtension } from '@/types/extensionTypes'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
export const useSidebarTabStore = defineStore('sidebarTab', () => {
const sidebarTabs = ref<SidebarTabExtension[]>([])
@@ -19,6 +24,16 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
const registerSidebarTab = (tab: SidebarTabExtension) => {
sidebarTabs.value = [...sidebarTabs.value, tab]
useCommandStore().registerCommand({
id: `Workspace.ToggleSidebarTab.${tab.id}`,
icon: tab.icon,
label: tab.tooltip,
tooltip: tab.tooltip,
versionAdded: '1.3.9',
function: () => {
toggleSidebarTab(tab.id)
}
})
}
const unregisterSidebarTab = (id: string) => {
@@ -34,12 +49,23 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
}
}
/**
* Register the core sidebar tabs.
*/
const registerCoreSidebarTabs = () => {
registerSidebarTab(useQueueSidebarTab())
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
}
return {
sidebarTabs,
activeSidebarTabId,
activeSidebarTab,
toggleSidebarTab,
registerSidebarTab,
unregisterSidebarTab
unregisterSidebarTab,
registerCoreSidebarTabs
}
})

View File

@@ -7,11 +7,14 @@ import { useSidebarTabStore } from './workspace/sidebarTabStore'
interface WorkspaceState {
spinner: boolean
// Whether the shift key is down globally
shiftDown: boolean
}
export const useWorkspaceStore = defineStore('workspace', {
state: (): WorkspaceState => ({
spinner: false
spinner: false,
shiftDown: false
}),
getters: {
toast(): ToastManager {
@@ -32,16 +35,6 @@ export const useWorkspaceStore = defineStore('workspace', {
actions: {
registerSidebarTab(tab: SidebarTabExtension) {
this.sidebarTab.registerSidebarTab(tab)
useCommandStore().registerCommand({
id: `Workspace.ToggleSidebarTab.${tab.id}`,
icon: tab.icon,
label: tab.tooltip,
tooltip: tab.tooltip,
versionAdded: '1.3.9',
function: () => {
this.sidebarTab.toggleSidebarTab(tab.id)
}
})
},
unregisterSidebarTab(id: string) {
this.sidebarTab.unregisterSidebarTab(id)

View File

@@ -116,7 +116,8 @@ const zExtraPngInfo = z
.passthrough()
const zExtraData = z.object({
extra_pnginfo: zExtraPngInfo,
/** extra_pnginfo can be missing is backend execution gets a validation error. */
extra_pnginfo: zExtraPngInfo.optional(),
client_id: z.string()
})
const zOutputsToExecute = z.array(zNodeId)
@@ -346,10 +347,10 @@ const zComfyOutputTypesSpec = z.array(
)
const zComfyNodeDef = z.object({
input: zComfyInputsSpec,
output: zComfyOutputTypesSpec,
output_is_list: z.array(z.boolean()),
output_name: z.array(z.string()),
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
output_is_list: z.array(z.boolean()).optional(),
output_name: z.array(z.string()).optional(),
output_tooltips: z.array(z.string()).optional(),
name: z.string(),
display_name: z.string(),

View File

@@ -12,7 +12,12 @@ export const zKeyCombo = z.object({
// Keybinding schema
export const zKeybinding = z.object({
commandId: z.string(),
combo: zKeyCombo
combo: zKeyCombo,
// Optional target element CSS selector to limit keybinding to.
// Note: Currently only used to distinguish between global keybindings
// and litegraph canvas keybindings.
// Do NOT use this field in extensions as it has no effect.
targetSelector: z.string().optional()
})
// Infer types from schemas

View File

@@ -1,3 +1,4 @@
// @ts-strict-ignore
import type {
ConnectingLink,
LGraphNode,

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