Merge branch 'main' into manager/menu-items-migration

This commit is contained in:
Jin Yi
2025-07-30 19:56:49 +09:00
committed by GitHub
182 changed files with 15831 additions and 2331 deletions

184
src/assets/icons/README.md Normal file
View File

@@ -0,0 +1,184 @@
# ComfyUI Custom Icons Guide
This guide explains how to add and use custom SVG icons in the ComfyUI frontend.
## Overview
ComfyUI uses a hybrid icon system that supports:
- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`)
- **Iconify** - Modern icon system with 200,000+ icons
- **Custom Icons** - Your own SVG icons
Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system.
## Quick Start
### 1. Add Your SVG Icon
Place your SVG file in the `custom/` directory:
```
src/assets/icons/custom/
└── your-icon.svg
```
### 2. Use in Components
```vue
<template>
<!-- Use as a Vue component -->
<i-comfy:your-icon />
<!-- In a PrimeVue button -->
<Button>
<template #icon>
<i-comfy:your-icon />
</template>
</Button>
</template>
```
## SVG Requirements
### File Naming
- Use kebab-case: `workflow-icon.svg`, `node-tree.svg`
- Avoid special characters and spaces
- The filename becomes the icon name
### SVG Format
```xml
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="..." />
</svg>
```
**Important:**
- Use `viewBox` for proper scaling (24x24 is standard)
- Don't include `width` or `height` attributes
- Use `currentColor` for theme-aware icons
- Keep SVGs optimized and simple
### Color Theming
For icons that adapt to the current theme, use `currentColor`:
```xml
<!-- ✅ Good: Uses currentColor -->
<svg viewBox="0 0 24 24">
<path stroke="currentColor" fill="none" d="..." />
</svg>
<!-- ❌ Bad: Hardcoded colors -->
<svg viewBox="0 0 24 24">
<path stroke="white" fill="black" d="..." />
</svg>
```
## Usage Examples
### Basic Icon
```vue
<i-comfy:workflow />
```
### With Classes
```vue
<i-comfy:workflow class="text-2xl text-blue-500" />
```
### In Buttons
```vue
<Button severity="secondary" text>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
### Conditional Icons
```vue
<template #icon>
<i-comfy:workflow v-if="isWorkflow" />
<i-comfy:node v-else />
</template>
```
## Technical Details
### How It Works
1. **unplugin-icons** automatically discovers SVG files in `custom/`
2. During build, SVGs are converted to Vue components
3. Components are tree-shaken - only used icons are bundled
4. The `i-` prefix and `comfy:` namespace identify custom icons
### Configuration
The icon system is configured in `vite.config.mts`:
```typescript
Icons({
compiler: 'vue3',
customCollections: {
'comfy': FileSystemIconLoader('src/assets/icons/custom'),
}
})
```
### TypeScript Support
Icons are automatically typed. If TypeScript doesn't recognize a new icon:
1. Restart your dev server
2. Check that the SVG file is valid
3. Ensure the filename follows kebab-case convention
## Troubleshooting
### Icon Not Showing
1. **Check filename**: Must be kebab-case without special characters
2. **Restart dev server**: Required after adding new icons
3. **Verify SVG**: Ensure it's valid SVG syntax
4. **Check console**: Look for Vue component resolution errors
### Icon Wrong Color
- Replace hardcoded colors with `currentColor`
- Use `stroke="currentColor"` for outlines
- Use `fill="currentColor"` for filled shapes
### Icon Wrong Size
- Remove `width` and `height` from SVG
- Ensure `viewBox` is present
- Use CSS classes for sizing: `class="w-6 h-6"`
## Best Practices
1. **Optimize SVGs**: Use tools like [SVGO](https://jakearchibald.github.io/svgomg/) to minimize file size
2. **Consistent viewBox**: Stick to 24x24 or 16x16 for consistency
3. **Semantic names**: Use descriptive names like `workflow-duplicate` not `icon1`
4. **Theme support**: Always use `currentColor` for adaptable icons
5. **Test both themes**: Verify icons look good in light and dark modes
## Migration from PrimeIcons
When replacing a PrimeIcon with a custom icon:
```vue
<!-- Before: PrimeIcon -->
<Button icon="pi pi-box" />
<!-- After: Custom icon -->
<Button>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
```
## Adding Icon Collections
To add an entire icon set from npm:
1. Install the icon package
2. Configure in `vite.config.mts`
3. Use with the appropriate prefix
See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details.

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 5V3C14 2.44772 13.5523 2 13 2H11C10.4477 2 10 2.44772 10 3V5C10 5.55228 10.4477 6 11 6H13C13.5523 6 14 5.55228 14 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M6 5V3C6 2.44772 5.55228 2 5 2H3C2.44772 2 2 2.44772 2 3V5C2 5.55228 2.44772 6 3 6H5C5.55228 6 6 5.55228 6 5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M14 13V11C14 10.4477 13.5523 10 13 10H11C10.4477 10 10 10.4477 10 11V13C10 13.5523 10.4477 14 11 14H13C13.5523 14 14 13.5523 14 13Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 4H6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
<path d="M10 12H8C5.79086 12 4 10.2091 4 8V6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@@ -30,10 +30,11 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
const settingsStore = useSettingStore()
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
@@ -49,7 +50,16 @@ const {
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
containerElement: document.body,
onMove: (event) => {
// Prevent dragging the menu over the top of the tabs
if (position.value === 'Top') {
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
if (event.y < minY) {
event.y = minY
}
}
}
})
// Update storedPosition when x or y changes
@@ -182,7 +192,6 @@ const adjustMenuPosition = () => {
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {

View File

@@ -1,47 +1,95 @@
<template>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<div
class="subgraph-breadcrumb w-auto"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
}"
:style="{
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Breadcrumb
class="bg-transparent"
:home="home"
ref="breadcrumbRef"
class="bg-transparent p-0"
:model="items"
aria-label="Graph navigation"
@item-click="handleItemClick"
/>
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:item="item"
:is-active="item === items.at(-1)"
/>
</template>
<template #separator
><span style="transform: scale(1.5)"> / </span></template
>
</Breadcrumb>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
const MIN_WIDTH = 28
const ITEM_GAP = 8
const ITEM_PADDING = 8
const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
const breadcrumbElement = computed(() => {
if (!breadcrumbRef.value) return null
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
return list
})
const items = computed(() => {
if (!navigationStore.navigationStack.length) return []
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph)
},
updateTitle: (title: string) => {
const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph
if (!rootGraph) return
forEachSubgraphNode(rootGraph, subgraph.id, (node) => {
node.title = title
})
}
}))
return [home.value, ...items]
})
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
key: 'root',
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -50,10 +98,6 @@ const home = computed(() => ({
}
}))
const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
@@ -65,21 +109,116 @@ useEventListener(document, 'keydown', (event) => {
)
}
})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
watch(breadcrumbElement, (el) => {
overflowObserver?.dispose()
overflowObserver = undefined
if (!el) return
overflowObserver = useOverflowObserver(el, {
onCheck: (isOverflowing) => {
overflowingTabs.value = isOverflowing
if (collapseTabs.value) {
// Items are currently hidden, check if we can show them
if (!isOverflowing) {
const items = [
...el.querySelectorAll('.p-breadcrumb-item')
] as HTMLElement[]
if (items.length < 3) return
const itemsWithIcon = items.filter((item) =>
item.querySelector('.p-breadcrumb-item-link-icon-visible')
).length
const separators = el.querySelectorAll(
'.p-breadcrumb-separator'
) as NodeListOf<HTMLElement>
const separator = separators[separators.length - 1] as HTMLElement
const separatorWidth = separator.offsetWidth
// items + separators + gaps + icons
const itemsWidth =
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
itemsWithIcon * ICON_WIDTH
const separatorsWidth = (items.length - 1) * separatorWidth
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
const containerWidth = el.clientWidth
if (totalWidth <= containerWidth) {
collapseTabs.value = false
}
}
} else if (isOverflowing) {
collapseTabs.value = true
}
}
})
})
// If e.g. the workflow name changes, we need to check the overflow again
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style>
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
<style scoped>
.subgraph-breadcrumb:not(:empty) {
flex: auto;
flex-shrink: 10000;
min-width: 120px;
}
color: #d26565;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
.subgraph-breadcrumb,
:deep(.p-breadcrumb) {
@apply overflow-hidden;
}
:deep(.p-breadcrumb-item) {
@apply flex items-center rounded-lg overflow-hidden;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
}
:deep(.p-breadcrumb-item:first-child) {
/* Then collapse the root workflow */
flex-shrink: 5000;
}
:deep(.p-breadcrumb-item:last-child) {
/* Then collapse the active item */
flex-shrink: 1;
}
:deep(.p-breadcrumb-item:hover),
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
color: var(--fg-color);
}
</style>
<style>
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
.p-breadcrumb-item,
.p-breadcrumb-separator {
@apply hidden;
}
.p-breadcrumb-item:nth-last-child(3),
.p-breadcrumb-separator:nth-last-child(2),
.p-breadcrumb-item:nth-last-child(1) {
@apply block;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<a
ref="wrapperRef"
v-tooltip.bottom="{
value: item.label,
showDelay: 512
}"
href="#"
class="cursor-pointer p-breadcrumb-item-link"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive
}"
@click="handleClick"
>
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
</a>
<Menu
v-if="isActive"
ref="menu"
:model="menuItems"
:popup="true"
:pt="{
root: {
style: 'background-color: var(--comfy-menu-secondary-bg)'
},
itemLink: {
class: 'py-2'
}
}"
/>
<InputText
v-if="isEditing"
ref="itemInputRef"
v-model="itemLabel"
class="fixed z-[10000] text-[.8rem] px-2 py-2"
@blur="inputBlur(true)"
@click.stop
@keydown.enter="inputBlur(true)"
@keydown.esc="inputBlur(false)"
/>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Menu, { MenuState } from 'primevue/menu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
item: MenuItem
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const dialogService = useDialogService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const isEditing = ref(false)
const itemLabel = ref<string>()
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
const wrapperRef = ref<HTMLAnchorElement>()
const rename = async (
newName: string | null | undefined,
initialName: string
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)
dialogService.showErrorDialog(error)
return
}
}
// Force the navigation stack to recompute the labels
// TODO: investigate if there is a better way to do this
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(navigationStore.exportState())
}
}
const menuItems = computed<MenuItem[]>(() => {
return [
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
},
{
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
},
{
separator: true
},
{
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await useCommandStore().execute('Comfy.ClearWorkflow')
}
},
{
separator: true,
visible: props.item.key === 'root'
},
{
label: t('breadcrumbsMenu.deleteWorkflow'),
icon: 'pi pi-times',
command: async () => {
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
},
visible: props.item.key === 'root'
}
]
})
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
return
}
if (event.detail === 1) {
if (props.isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
})
}
}
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
}
isEditing.value = false
}
</script>
<style scoped>
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
}
.p-breadcrumb-item-link {
@apply overflow-hidden;
padding: var(--p-breadcrumb-item-padding);
}
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
</style>

View File

@@ -2,7 +2,7 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Missing Node Types"
title="Some Nodes Are Missing"
message="When loading the graph, the following node types were not found"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />

View File

@@ -11,7 +11,6 @@
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
@@ -21,24 +20,35 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode
const visible =
// Early exit for non-visible widgets
if (!widget.isVisible()) {
widgetState.visible = false
continue
}
// Check if the widget's node is in the current graph
const node = widget.node
const isInCorrectGraph = currentGraph?.nodes.includes(node)
widgetState.visible =
!!isInCorrectGraph &&
lgCanvas.isNodeVisible(node) &&
!(widget.options.hideOnZoom && lowQuality) &&
widget.isVisible()
!(widget.options.hideOnZoom && lowQuality)
widgetState.visible = visible
if (visible) {
if (widgetState.visible && node) {
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [

View File

@@ -16,9 +16,14 @@
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
</LiteGraphCanvasSplitterOverlay>
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
@@ -50,9 +55,9 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
@@ -67,12 +72,12 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -114,6 +119,10 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -192,22 +201,26 @@ watch(
}
)
// Update the progress of the executing node
// Update the progress of executing nodes
watch(
() =>
[
executionStore.executingNodeId,
executionStore.executingNodeProgress
] satisfies [NodeId | null, number | null],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
node.progress = executingNodeProgress ?? undefined
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
const progressState = nodeLocationProgressStates[nodeLocatorId]
if (progressState && progressState.state === 'running') {
node.progress = progressState.value / progressState.max
} else {
node.progress = undefined
}
}
}
// Force canvas redraw to ensure progress updates are visible
canvas.graph.setDirtyCanvas(true, false)
},
{ deep: true }
)
// Update node slot errors
@@ -345,6 +358,13 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -1,6 +1,7 @@
<template>
<ButtonGroup
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
@wheel="canvasInteractions.handleWheel"
>
<Button
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
@@ -56,6 +57,15 @@
data-testid="toggle-link-visibility-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
/>
<Button
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' (Alt + m)'"
severity="secondary"
:icon="'pi pi-map'"
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
:class="{ 'minimap-active': minimapVisible }"
data-testid="toggle-minimap-button"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
/>
</ButtonGroup>
</template>
@@ -66,6 +76,7 @@ import ButtonGroup from 'primevue/buttongroup'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
@@ -74,7 +85,9 @@ const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
@@ -107,4 +120,15 @@ const stopRepeat = () => {
margin: 0;
border-radius: 0;
}
.p-button.minimap-active {
background-color: var(--p-button-primary-background);
border-color: var(--p-button-primary-border-color);
color: var(--p-button-primary-color);
}
.p-button.minimap-active:hover {
background-color: var(--p-button-primary-hover-background);
border-color: var(--p-button-primary-hover-border-color);
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div
v-if="visible && initialized"
ref="containerRef"
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
:style="containerStyles"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
@wheel="handleWheel"
>
<canvas
ref="canvasRef"
:width="width"
:height="height"
class="minimap-canvas"
/>
<div class="minimap-viewport" :style="viewportStyles" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const {
initialized,
visible,
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel
} = minimap
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
})
onUnmounted(() => {
destroy()
})
</script>
<style scoped>
.litegraph-minimap {
overflow: hidden;
}
.minimap-canvas {
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.minimap-viewport {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
</style>

View File

@@ -5,6 +5,7 @@
header: 'hidden',
content: 'p-0 flex flex-row'
}"
@wheel="canvasInteractions.handleWheel"
>
<ExecuteButton />
<ColorPickerButton />
@@ -39,6 +40,7 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -46,6 +48,7 @@ import { useCanvasStore } from '@/stores/graphStore'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(

View File

@@ -7,9 +7,12 @@
}"
severity="secondary"
text
icon="pi pi-box"
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
/>
>
<template #icon>
<i-comfy:workflow />
</template>
</Button>
</template>
<script setup lang="ts">

View File

@@ -19,7 +19,7 @@
<script setup lang="ts">
import { useElementBounding, useEventListener } from '@vueuse/core'
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useDomClipping } from '@/composables/element/useDomClipping'
@@ -61,10 +61,13 @@ const updateDomClipping = () => {
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
if (!selectedNode) return
if (!selectedNode) {
// Clear clipping when no node is selected
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
return
}
const node = widget.node
const isSelected = selectedNode === node
const isSelected = selectedNode === widget.node
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -122,7 +125,10 @@ watch(
}
)
if (isDOMWidget(widget)) {
// Set up event listeners only after the widget is mounted and visible
const setupDOMEventListeners = () => {
if (!isDOMWidget(widget) || !widgetState.visible) return
if (widget.element.blur) {
useEventListener(document, 'mousedown', (event) => {
if (!widget.element.contains(event.target as HTMLElement)) {
@@ -140,14 +146,46 @@ if (isDOMWidget(widget)) {
}
}
// Set up event listeners when widget becomes visible
watch(
() => widgetState.visible,
(visible) => {
if (visible) {
setupDOMEventListeners()
}
},
{ immediate: true }
)
const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
// Mount DOM element when widget is or becomes visible
const mountElementIfVisible = () => {
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
// Only append if not already a child
if (!widgetElement.value.contains(widget.element)) {
widgetElement.value.appendChild(widget.element)
}
}
}
// Check on mount - but only after next tick to ensure visibility is calculated
onMounted(() => {
nextTick(() => {
mountElementIfVisible()
}).catch((error) => {
console.error('Error mounting DOM widget element:', error)
})
})
// And watch for visibility changes
watch(
() => widgetState.visible,
() => {
mountElementIfVisible()
}
)
</script>
<style scoped>

View File

@@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
defineProps<{
const props = defineProps<{
widget?: object
nodeId: NodeId
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let executingNodeId: NodeId | null = null
let parentNodeId: NodeId | null = null
onMounted(() => {
executingNodeId = executionStore.executingNodeId
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNode, () => executionStore.isIdle],
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
}
}
)

View File

@@ -14,9 +14,8 @@
/>
<div class="side-tool-bar-end">
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
<SidebarThemeToggleIcon />
<SidebarHelpCenterIcon />
<SidebarSettingsToggleIcon />
<SidebarBottomPanelToggleButton />
</div>
</nav>
</teleport>
@@ -32,6 +31,7 @@
import { computed } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import { useKeybindingStore } from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
import { useUserStore } from '@/stores/userStore'
@@ -41,8 +41,6 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
const workspaceStore = useWorkspaceStore()
const settingStore = useSettingStore()

View File

@@ -0,0 +1,19 @@
<template>
<SidebarIcon
:tooltip="$t('menu.toggleBottomPanel')"
:selected="bottomPanelStore.bottomPanelVisible"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-ph:terminal-bold />
</template>
</SidebarIcon>
</template>
<script setup lang="ts">
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import SidebarIcon from './SidebarIcon.vue'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -19,10 +19,12 @@
@click="emit('click', $event)"
>
<template #icon>
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<i :class="icon + ' side-bar-button-icon'" />
</OverlayBadge>
<i v-else :class="icon + ' side-bar-button-icon'" />
</slot>
</template>
</Button>
</template>

View File

@@ -1,25 +0,0 @@
<template>
<SidebarIcon
icon="pi pi-cog"
class="comfy-settings-btn"
:tooltip="$t('g.settings')"
@click="showSetting"
/>
</template>
<script setup lang="ts">
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import SidebarIcon from './SidebarIcon.vue'
const dialogStore = useDialogStore()
const showSetting = () => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent
})
}
</script>

View File

@@ -1,29 +0,0 @@
<template>
<SidebarIcon
:icon="icon"
:tooltip="$t('sideToolbar.themeToggle')"
class="comfy-vue-theme-toggle"
@click="toggleTheme"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import SidebarIcon from './SidebarIcon.vue'
const colorPaletteStore = useColorPaletteStore()
const icon = computed(() =>
colorPaletteStore.completedActivePalette.light_theme
? 'pi pi-sun'
: 'pi pi-moon'
)
const commandStore = useCommandStore()
const toggleTheme = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
</script>

View File

@@ -1,25 +0,0 @@
<template>
<Button
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
severity="secondary"
text
:aria-label="$t('menu.toggleBottomPanel')"
@click="bottomPanelStore.toggleBottomPanel"
>
<template #icon>
<i-material-symbols:dock-to-bottom
v-if="bottomPanelStore.bottomPanelVisible"
/>
<i-material-symbols:dock-to-bottom-outline v-else />
</template>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -1,51 +1,116 @@
<template>
<Menubar
:model="translatedItems"
class="top-menubar border-none p-0 bg-transparent"
:pt="{
rootList: 'gap-0 flex-nowrap w-auto',
submenu: `dropdown-direction-${dropdownDirection}`,
item: 'relative'
<div
class="comfyui-logo-wrapper p-1 flex justify-center items-center cursor-pointer rounded-md mr-2"
:class="{
'comfyui-logo-menu-visible': menuRef?.visible
}"
:style="{
minWidth: isLargeSidebar ? '4rem' : 'auto'
}"
@click="menuRef?.toggle($event)"
>
<template #item="{ item, props, root }">
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo h-7"
@contextmenu="showNativeSystemMenu"
/>
<i class="pi pi-angle-down ml-1 text-[10px]" />
</div>
<TieredMenu
ref="menuRef"
:model="translatedItems"
:popup="true"
class="comfy-command-menu"
:class="{
'comfy-command-menu-top': isTopMenu
}"
@show="onMenuShow"
>
<template #item="{ item, props }">
<div
v-if="item.key === 'theme'"
class="flex items-center gap-4 px-4 py-5"
@click.stop.prevent
>
{{ item.label }}
<SelectButton
:options="[darkLabel, lightLabel]"
:model-value="activeTheme"
@click.stop.prevent
@update:model-value="onThemeChange"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<i v-if="option === lightLabel" class="pi pi-sun" />
<i v-if="option === darkLabel" class="pi pi-moon" />
<span>{{ option }}</span>
</div>
</template>
</SelectButton>
</div>
<a
class="p-menubar-item-link"
v-else
class="p-menubar-item-link px-4 py-2"
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 class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<span
v-if="item?.comfyCommand?.keybinding"
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
>
{{ item.comfyCommand.keybinding.combo.toString() }}
</span>
<i v-if="!root && item.items" class="ml-auto pi pi-angle-right" />
<i v-if="item.items" class="ml-auto pi pi-angle-right" />
</a>
</template>
</Menubar>
</TieredMenu>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import Menubar from 'primevue/menubar'
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import SelectButton from 'primevue/selectbutton'
import TieredMenu, {
type TieredMenuMethods,
type TieredMenuState
} from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
const settingStore = useSettingStore()
const dropdownDirection = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top' ? 'down' : 'up'
)
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const aboutPanelStore = useAboutPanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const isLargeSidebar = computed(
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
)
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
const translateMenuItem = (item: MenuItem): MenuItem => {
const label = typeof item.label === 'function' ? item.label() : item.label
const translatedLabel = label
@@ -59,9 +124,119 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
}
}
const translatedItems = computed(() =>
menuItemsStore.menuItems.map(translateMenuItem)
)
const showSettings = (defaultPanel?: string) => {
dialogStore.showDialog({
key: 'global-settings',
headerComponent: SettingDialogHeader,
component: SettingDialogContent,
props: {
defaultPanel
}
})
}
// Temporary duplicated from LoadWorkflowWarning.vue
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const showManageExtensions = () => {
if (isManagerInstalled.value) {
useDialogService().showManagerDialog()
} else {
showSettings('extension')
}
}
const extraMenuItems: MenuItem[] = [
{ separator: true },
{
key: 'theme',
label: t('menu.theme')
},
{ separator: true },
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
command: showManageExtensions
},
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
command: () => showSettings()
}
]
const lightLabel = t('menu.light')
const darkLabel = t('menu.dark')
const activeTheme = computed(() => {
return colorPaletteStore.completedActivePalette.light_theme
? lightLabel
: darkLabel
})
const onThemeChange = async () => {
await commandStore.execute('Comfy.ToggleTheme')
}
const translatedItems = computed(() => {
const items = menuItemsStore.menuItems.map(translateMenuItem)
let helpIndex = items.findIndex((item) => item.key === 'Help')
let helpItem: MenuItem | undefined
if (helpIndex !== -1) {
items[helpIndex].icon = 'mdi mdi-help-circle-outline'
// If help is not the last item (i.e. we have extension commands), separate them
const isLastItem = helpIndex !== items.length - 1
helpItem = items.splice(
helpIndex,
1,
...(isLastItem
? [
{
separator: true
}
]
: [])
)[0]
}
helpIndex = items.length
items.splice(
helpIndex,
0,
...extraMenuItems,
...(helpItem
? [
{
separator: true
},
helpItem
]
: [])
)
return items
})
const onMenuShow = () => {
void nextTick(() => {
// Force the menu to show submenus on hover
if (menuRef.value) {
menuRef.value.dirty = true
}
})
}
</script>
<style scoped>
@@ -74,4 +249,20 @@ const translatedItems = computed(() =>
border-color: var(--p-content-border-color);
border-style: solid;
}
.comfyui-logo-menu-visible,
.comfyui-logo-wrapper:hover {
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
}
</style>
<style>
.comfy-command-menu ul {
background-color: var(--comfy-menu-secondary-bg) !important;
}
.comfy-command-menu-top .p-tieredmenu-submenu {
left: calc(100% + 15px) !important;
top: -4px !important;
}
</style>

View File

@@ -1,82 +1,66 @@
<template>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<img
src="/assets/images/comfy-logo-mono.svg"
alt="ComfyUI Logo"
class="comfyui-logo ml-2 app-drag h-6"
/>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
<BottomPanelToggleButton class="flex-shrink-0" />
<Button
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
class="flex-shrink-0"
icon="pi pi-bars"
severity="secondary"
text
:aria-label="$t('menu.hideMenu')"
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<div>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
class="w-full flex content-end z-[1001] h-[38px]"
style="background: var(--border-color)"
>
<WorkflowTabs />
</div>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<CommandMenubar />
<div class="flex-grow min-w-0 app-drag h-full"></div>
<div
ref="menuRight"
class="comfyui-menu-right flex-shrink-1 overflow-auto"
/>
<Actionbar />
<CurrentUserButton class="flex-shrink-0" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
<script setup lang="ts">
import { useEventBus } from '@vueuse/core'
import Button from 'primevue/button'
import { computed, onMounted, provide, ref } from 'vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeSystemMenu
} from '@/utils/envUtil'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const menuRight = ref<HTMLDivElement | null>(null)
// Menu-right holds legacy topbar elements attached by custom scripts
onMounted(() => {
if (menuRight.value) {
app.menu.element.style.width = 'fit-content'
menuRight.value.appendChild(app.menu.element)
}
})
@@ -138,4 +122,35 @@ onMounted(() => {
.dark-theme .comfyui-logo {
filter: invert(1);
}
.comfyui-menu-button-hide {
background-color: var(--comfy-menu-secondary-bg);
border-left: 1px solid var(--border-color);
}
</style>
<style>
.comfyui-menu-right::-webkit-scrollbar {
max-height: 5px;
}
.comfyui-menu-right:hover::-webkit-scrollbar {
cursor: grab;
}
.comfyui-menu-right::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 60%, transparent);
}
.comfyui-menu-right:hover::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--border-color) 80%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--fg-color) 30%, transparent);
}
.comfyui-menu-right::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--fg-color) 80%, transparent);
}
</style>

View File

@@ -1,8 +1,12 @@
<template>
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
<span
v-tooltip.bottom="workflowOption.workflow.key"
class="workflow-label text-sm max-w-[150px] truncate inline-block"
v-tooltip.bottom="{
value: workflowOption.workflow.key,
class: 'workflow-tab-tooltip',
showDelay: 512
}"
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
>
{{ workflowOption.workflow.filename }}
</span>
@@ -142,3 +146,9 @@ usePragmaticDroppable(tabGetter, {
transform: translate(-50%, -50%);
}
</style>
<style>
.p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important;
}
</style>

View File

@@ -1,5 +1,17 @@
<template>
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
<div
class="workflow-tabs-container flex flex-row max-w-full h-full flex-auto overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-left"
text
severity="secondary"
class="overflow-arrow overflow-arrow-left"
:disabled="!leftArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<ScrollPanel
ref="scrollPanelRef"
class="overflow-hidden no-drag"
@@ -27,9 +39,18 @@
</template>
</SelectButton>
</ScrollPanel>
<Button
v-if="showOverflowArrows"
icon="pi pi-chevron-right"
text
severity="secondary"
class="overflow-arrow overflow-arrow-right"
:disabled="!rightArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(1))"
/>
<Button
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
class="new-blank-workflow-button flex-shrink-0 no-drag"
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
icon="pi pi-plus"
text
severity="secondary"
@@ -37,23 +58,32 @@
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
/>
<ContextMenu ref="menu" :model="contextMenuItems" />
<div
v-if="menuSetting !== 'Bottom' && isDesktop"
class="window-actions-spacer flex-shrink-0 app-drag"
/>
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import Button from 'primevue/button'
import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
interface WorkflowOption {
value: string
@@ -67,11 +97,19 @@ const props = defineProps<{
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const settingStore = useSettingStore()
const workflowService = useWorkflowService()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const scrollPanelRef = ref()
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
const isDesktop = isElectron()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
value: workflow.path,
@@ -86,6 +124,7 @@ const selectedWorkflow = computed<WorkflowOption | null>(() =>
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
: null
)
const onWorkflowChange = async (option: WorkflowOption) => {
// Prevent unselecting the current workflow
if (!option) {
@@ -178,6 +217,13 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scroll = (direction: number) => {
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
}
// Scroll to active offscreen tab when opened
watch(
() => workflowStore.activeWorkflow,
@@ -208,12 +254,71 @@ watch(
},
{ immediate: true }
)
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
})
},
{ immediate: true }
)
})
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style scoped>
.workflow-tabs-container {
background-color: var(--comfy-menu-secondary-bg);
}
:deep(.p-togglebutton) {
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
@apply p-0 bg-transparent rounded-none flex-shrink relative border-0 border-r border-solid;
border-right-color: var(--border-color);
min-width: 90px;
}
.overflow-arrow {
@apply px-2 rounded-none;
}
.overflow-arrow[disabled] {
@apply opacity-25;
}
:deep(.p-togglebutton > .p-togglebutton-content) {
@apply max-w-full;
}
:deep(.workflow-tab) {
@apply max-w-full;
}
:deep(.p-togglebutton::before) {
@@ -255,6 +360,10 @@ watch(
@apply h-full;
}
:deep(.workflow-tabs) {
display: flex;
}
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
@@ -264,4 +373,15 @@ watch(
:deep(.p-selectbutton) {
@apply rounded-none h-full;
}
.workflow-tabs-container-desktop {
max-width: env(titlebar-area-width, 100vw);
}
.window-actions-spacer {
@apply flex-auto;
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
min-width: var(--window-actions-spacer-width);
}
</style>

View File

@@ -0,0 +1,136 @@
import { LGraphCanvas } from '@comfyorg/litegraph'
import { onUnmounted, ref } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called when sync stops
*/
onStop?: () => void
}
interface CanvasTransform {
scale: number
offsetX: number
offsetY: number
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
*
* The sync function typically reads canvas.ds properties like offset and scale to keep
* Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const syncWithCanvas = (canvas: LGraphCanvas) => {
* canvas.ds.scale
* canvas.ds.offset
* }
*
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* syncWithCanvas,
* {
* autoStart: false,
* onStart: () => emit('rafStatusChange', true),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
syncFn: (canvas: LGraphCanvas) => void,
options: CanvasTransformSyncOptions = {}
) {
const { onStart, onStop, autoStart = true } = options
const { getCanvas } = useCanvasStore()
const isActive = ref(false)
let rafId: number | null = null
let lastTransform: CanvasTransform = {
scale: 0,
offsetX: 0,
offsetY: 0
}
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
const ds = canvas.ds
return (
ds.scale !== lastTransform.scale ||
ds.offset[0] !== lastTransform.offsetX ||
ds.offset[1] !== lastTransform.offsetY
)
}
const sync = () => {
if (!isActive.value) return
const canvas = getCanvas()
if (!canvas) return
try {
// Only run sync if transform actually changed
if (hasTransformChanged(canvas)) {
lastTransform = {
scale: canvas.ds.scale,
offsetX: canvas.ds.offset[0],
offsetY: canvas.ds.offset[1]
}
syncFn(canvas)
}
} catch (error) {
console.error('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
const startSync = () => {
if (isActive.value) return
isActive.value = true
onStart?.()
// Reset last transform to force initial sync
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
sync()
}
const stopSync = () => {
isActive.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
onStop?.()
}
onUnmounted(stopSync)
if (autoStart) {
startSync()
}
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,8 +1,9 @@
import type { Size, Vector2 } from '@comfyorg/litegraph'
import { CSSProperties, ref } from 'vue'
import { CSSProperties, ref, watch } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
export interface PositionConfig {
/* The position of the element on litegraph canvas */
@@ -18,9 +19,18 @@ export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { canvasPosToClientPos } = useCanvasPositionConversion(
lgCanvas.canvas,
lgCanvas
const { canvasPosToClientPos, update: updateCanvasPosition } =
useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
const settingStore = useSettingStore()
watch(
[
() => settingStore.get('Comfy.Sidebar.Location'),
() => settingStore.get('Comfy.Sidebar.Size'),
() => settingStore.get('Comfy.UseNewMenu')
],
() => updateCanvasPosition(),
{ flush: 'post' }
)
/**

View File

@@ -11,7 +11,7 @@ export const useCanvasPositionConversion = (
canvasElement: Parameters<typeof useElementBounding>[0],
lgCanvas: LGraphCanvas
) => {
const { left, top } = useElementBounding(canvasElement)
const { left, top, update } = useElementBounding(canvasElement)
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
const { offset, scale } = lgCanvas.ds
@@ -31,6 +31,7 @@ export const useCanvasPositionConversion = (
return {
clientPosToCanvasPos,
canvasPosToClientPos
canvasPosToClientPos,
update
}
}

View File

@@ -0,0 +1,64 @@
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
import { debounce } from 'lodash'
import { readonly, ref } from 'vue'
/**
* Observes an element for overflow changes and optionally debounces the check
* @param element - The element to observe
* @param options - The options for the observer
* @param options.debounceTime - The time to debounce the check in milliseconds
* @param options.useMutationObserver - Whether to use a mutation observer to check for overflow
* @param options.useResizeObserver - Whether to use a resize observer to check for overflow
* @returns An object containing the isOverflowing state and the checkOverflow function to manually trigger
*/
export const useOverflowObserver = (
element: HTMLElement,
options?: {
debounceTime?: number
useMutationObserver?: boolean
useResizeObserver?: boolean
onCheck?: (isOverflowing: boolean) => void
}
) => {
options = {
debounceTime: 25,
useMutationObserver: true,
useResizeObserver: true,
...options
}
const isOverflowing = ref(false)
const disposeFns: (() => void)[] = []
const disposed = ref(false)
const checkOverflowFn = () => {
isOverflowing.value = element.scrollWidth > element.clientWidth
options.onCheck?.(isOverflowing.value)
}
const checkOverflow = options.debounceTime
? debounce(checkOverflowFn, options.debounceTime)
: checkOverflowFn
if (options.useMutationObserver) {
disposeFns.push(
useMutationObserver(element, checkOverflow, {
subtree: true,
childList: true
}).stop
)
}
if (options.useResizeObserver) {
disposeFns.push(useResizeObserver(element, checkOverflow).stop)
}
return {
isOverflowing: readonly(isOverflowing),
disposed: readonly(disposed),
checkOverflow,
dispose: () => {
disposed.value = true
disposeFns.forEach((fn) => fn())
}
}
}

View File

@@ -0,0 +1,59 @@
import { computed } from 'vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
/**
* Composable for handling canvas interactions from Vue components.
* This provides a unified way to forward events to the LiteGraph canvas
* and will be the foundation for migrating canvas interactions to Vue.
*/
export function useCanvasInteractions() {
const settingStore = useSettingStore()
const isStandardNavMode = computed(
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
*/
const handleWheel = (event: WheelEvent) => {
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
event.preventDefault() // Prevent browser zoom
forwardEventToCanvas(event)
return
}
// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) {
event.preventDefault()
forwardEventToCanvas(event)
return
}
// Otherwise, let the component handle it normally
}
/**
* Forwards an event to the LiteGraph canvas
*/
const forwardEventToCanvas = (
event: WheelEvent | PointerEvent | MouseEvent
) => {
const canvasEl = app.canvas?.canvas
if (!canvasEl) return
// Create new event with same properties
const EventConstructor = event.constructor as typeof WheelEvent
const newEvent = new EventConstructor(event.type, event)
canvasEl.dispatchEvent(newEvent)
}
return {
handleWheel,
forwardEventToCanvas
}
}

View File

@@ -30,6 +30,25 @@ function safePricingExecution(
}
}
/**
* Helper function to calculate Runway duration-based pricing
* @param node - The LiteGraph node
* @returns Formatted price string
*/
const calculateRunwayDurationPrice = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return '$0.05/second'
const duration = Number(durationWidget.value)
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
const validDuration = isNaN(duration) ? 5 : duration
const cost = (0.05 * validDuration).toFixed(2)
return `$${cost}/Run`
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -110,15 +129,27 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
FluxProUltraImageNode: {
displayPrice: '$0.06/Run'
},
FluxProKontextProNode: {
displayPrice: '$0.04/Run'
},
FluxProKontextMaxNode: {
displayPrice: '$0.08/Run'
},
IdeogramV1: {
displayPrice: (node: LGraphNode): string => {
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
if (!numImagesWidget) return '$0.06 x num_images/Run'
const turboWidget = node.widgets?.find(
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const cost = (0.06 * numImages).toFixed(2)
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.02 : 0.06
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
},
@@ -127,10 +158,16 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const numImagesWidget = node.widgets?.find(
(w) => w.name === 'num_images'
) as IComboWidget
if (!numImagesWidget) return '$0.08 x num_images/Run'
const turboWidget = node.widgets?.find(
(w) => w.name === 'turbo'
) as IComboWidget
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
const numImages = Number(numImagesWidget.value) || 1
const cost = (0.08 * numImages).toFixed(2)
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
const basePrice = turbo ? 0.05 : 0.08
const cost = (basePrice * numImages).toFixed(2)
return `$${cost}/Run`
}
},
@@ -651,10 +688,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (duration.includes('5')) {
if (resolution.includes('720p')) return '$0.3/Run'
if (resolution.includes('1080p')) return '~$0.3/Run'
if (resolution.includes('1080p')) return '$0.5/Run'
} else if (duration.includes('10')) {
if (resolution.includes('720p')) return '$0.25/Run'
if (resolution.includes('1080p')) return '$1.0/Run'
if (resolution.includes('720p')) return '$0.4/Run'
if (resolution.includes('1080p')) return '$1.5/Run'
}
return '$0.3/Run'
@@ -678,9 +715,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
if (duration.includes('5')) {
if (resolution.includes('720p')) return '$0.2/Run'
if (resolution.includes('1080p')) return '~$0.45/Run'
if (resolution.includes('1080p')) return '$0.3/Run'
} else if (duration.includes('10')) {
if (resolution.includes('720p')) return '$0.6/Run'
if (resolution.includes('720p')) return '$0.25/Run'
if (resolution.includes('1080p')) return '$1.0/Run'
}
@@ -896,18 +933,11 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
}
const model = String(modelWidget.value)
const aspectRatio = String(aspectRatioWidget.value)
if (model.includes('photon-flash-1')) {
if (aspectRatio.includes('1:1')) return '$0.0045/Run'
if (aspectRatio.includes('16:9')) return '$0.0045/Run'
if (aspectRatio.includes('4:3')) return '$0.0046/Run'
if (aspectRatio.includes('21:9')) return '$0.0047/Run'
return '$0.0019/Run'
} else if (model.includes('photon-1')) {
if (aspectRatio.includes('1:1')) return '$0.0172/Run'
if (aspectRatio.includes('16:9')) return '$0.0172/Run'
if (aspectRatio.includes('4:3')) return '$0.0176/Run'
if (aspectRatio.includes('21:9')) return '$0.0182/Run'
return '$0.0073/Run'
}
return '$0.0172/Run'
@@ -918,31 +948,17 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const aspectRatioWidget = node.widgets?.find(
(w) => w.name === 'aspect_ratio'
) as IComboWidget
if (!modelWidget) {
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
return '$0.0019-0.0073/Run (varies with model)'
}
const model = String(modelWidget.value)
const aspectRatio = aspectRatioWidget
? String(aspectRatioWidget.value)
: null
if (model.includes('photon-flash-1')) {
if (!aspectRatio) return '$0.0045/Run'
if (aspectRatio.includes('1:1')) return '~$0.0045/Run'
if (aspectRatio.includes('16:9')) return '~$0.0045/Run'
if (aspectRatio.includes('4:3')) return '~$0.0046/Run'
if (aspectRatio.includes('21:9')) return '~$0.0047/Run'
return '$0.0019/Run'
} else if (model.includes('photon-1')) {
if (!aspectRatio) return '$0.0172/Run'
if (aspectRatio.includes('1:1')) return '~$0.0172/Run'
if (aspectRatio.includes('16:9')) return '~$0.0172/Run'
if (aspectRatio.includes('4:3')) return '~$0.0176/Run'
if (aspectRatio.includes('21:9')) return '~$0.0182/Run'
return '$0.0073/Run'
}
return '$0.0172/Run'
@@ -1004,6 +1020,279 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return '$2.25/Run'
}
},
// Runway nodes - using actual node names from ComfyUI
RunwayTextToImageNode: {
displayPrice: '$0.08/Run'
},
RunwayImageToVideoNodeGen3a: {
displayPrice: calculateRunwayDurationPrice
},
RunwayImageToVideoNodeGen4: {
displayPrice: calculateRunwayDurationPrice
},
RunwayFirstLastFrameNode: {
displayPrice: calculateRunwayDurationPrice
},
// Rodin nodes - all have the same pricing structure
Rodin3D_Regular: {
displayPrice: '$0.4/Run'
},
Rodin3D_Detail: {
displayPrice: '$0.4/Run'
},
Rodin3D_Smooth: {
displayPrice: '$0.4/Run'
},
Rodin3D_Sketch: {
displayPrice: '$0.4/Run'
},
// Tripo nodes - using actual node names from ComfyUI
TripoTextToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.10/Run'
else return '$0.15/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
} else {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.15/Run'
else return '$0.20/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
} else {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
}
}
}
}
},
TripoImageToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Image to Model
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
},
TripoRefineNode: {
displayPrice: '$0.3/Run'
},
TripoTextureNode: {
displayPrice: (node: LGraphNode): string => {
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)'
const textureQuality = String(textureQualityWidget.value)
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
}
},
TripoConvertModelNode: {
displayPrice: '$0.10/Run'
},
TripoRetargetRiggedModelNode: {
displayPrice: '$0.10/Run'
},
TripoMultiviewToModelNode: {
displayPrice: (node: LGraphNode): string => {
const quadWidget = node.widgets?.find(
(w) => w.name === 'quad'
) as IComboWidget
const styleWidget = node.widgets?.find(
(w) => w.name === 'style'
) as IComboWidget
const textureWidget = node.widgets?.find(
(w) => w.name === 'texture'
) as IComboWidget
const textureQualityWidget = node.widgets?.find(
(w) => w.name === 'texture_quality'
) as IComboWidget
if (!quadWidget || !styleWidget || !textureWidget)
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
const quad = String(quadWidget.value).toLowerCase() === 'true'
const style = String(styleWidget.value).toLowerCase()
const texture = String(textureWidget.value).toLowerCase() === 'true'
const textureQuality = String(
textureQualityWidget?.value || 'standard'
).toLowerCase()
// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
if (style.includes('none')) {
if (!quad) {
if (!texture) return '$0.20/Run'
else return '$0.25/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.40/Run'
else return '$0.45/Run'
} else {
if (!texture) return '$0.30/Run'
else return '$0.35/Run'
}
}
} else {
// any style
if (!quad) {
if (!texture) return '$0.25/Run'
else return '$0.30/Run'
} else {
if (textureQuality.includes('detailed')) {
if (!texture) return '$0.45/Run'
else return '$0.50/Run'
} else {
if (!texture) return '$0.35/Run'
else return '$0.40/Run'
}
}
}
}
},
// Google/Gemini nodes
GeminiNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Google Veo video generation
if (model.includes('veo-2.0')) {
return '$0.5/second'
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
return '$0.00016/$0.0006 per 1K tokens'
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
return '$0.00125/$0.01 per 1K tokens'
}
// For other Gemini models, show token-based pricing info
return 'Token-based'
}
},
// OpenAI nodes
OpenAIChatNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
if (!modelWidget) return 'Token-based'
const model = String(modelWidget.value)
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
if (model.includes('o4-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o1-pro')) {
return '$0.15/$0.60 per 1K tokens'
} else if (model.includes('o1')) {
return '$0.015/$0.06 per 1K tokens'
} else if (model.includes('o3-mini')) {
return '$0.0011/$0.0044 per 1K tokens'
} else if (model.includes('o3')) {
return '$0.01/$0.04 per 1K tokens'
} else if (model.includes('gpt-4o')) {
return '$0.0025/$0.01 per 1K tokens'
} else if (model.includes('gpt-4.1-nano')) {
return '$0.0001/$0.0004 per 1K tokens'
} else if (model.includes('gpt-4.1-mini')) {
return '$0.0004/$0.0016 per 1K tokens'
} else if (model.includes('gpt-4.1')) {
return '$0.002/$0.008 per 1K tokens'
}
return 'Token-based'
}
}
}
@@ -1045,9 +1334,11 @@ export const useNodePricing = () => {
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],
OpenAIGPTImage1: ['quality', 'n'],
IdeogramV1: ['num_images'],
IdeogramV2: ['num_images'],
IdeogramV1: ['num_images', 'turbo'],
IdeogramV2: ['num_images', 'turbo'],
IdeogramV3: ['rendering_speed', 'num_images'],
FluxProKontextProNode: [],
FluxProKontextMaxNode: [],
VeoVideoGenerationNode: ['duration_seconds'],
LumaVideoNode: ['model', 'resolution', 'duration'],
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
@@ -1075,7 +1366,19 @@ export const useNodePricing = () => {
RecraftGenerateVectorImageNode: ['n'],
MoonvalleyTxt2VideoNode: ['length'],
MoonvalleyImg2VideoNode: ['length'],
MoonvalleyVideo2VideoNode: ['length']
MoonvalleyVideo2VideoNode: ['length'],
// Runway nodes
RunwayImageToVideoNodeGen3a: ['duration'],
RunwayImageToVideoNodeGen4: ['duration'],
RunwayFirstLastFrameNode: ['duration'],
// Tripo nodes
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
TripoTextureNode: ['texture_quality'],
// Google/Gemini nodes
GeminiNode: ['model'],
// OpenAI nodes
OpenAIChatNode: ['model']
}
return widgetMap[nodeType] || []
}

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
/**
* Composable to find missing NodePacks from workflow
@@ -56,7 +57,7 @@ export const useMissingNodes = () => {
}
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
const missingNodes = collectAllNodes(app.graph, isMissingCoreNode)
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
})

View File

@@ -9,6 +9,7 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
type WorkflowPack = {
id:
@@ -109,11 +110,13 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
}
/**
* Get the node packs for all nodes in the workflow.
* Get the node packs for all nodes in the workflow (including subgraphs).
*/
const getWorkflowPacks = async () => {
if (!app.graph?.nodes?.length) return []
const packs = await Promise.all(app.graph.nodes.map(workflowNodeToPack))
if (!app.graph) return []
const allNodes = collectAllNodes(app.graph)
if (!allNodes.length) return []
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
workflowPacks.value = packs.filter((pack) => pack !== undefined)
}

View File

@@ -1,6 +1,7 @@
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
@@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => {
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const nodeExecutionTitle = computed(() => {
// Check if any nodes are in progress
const nodeProgressEntries = Object.entries(
executionStore.nodeProgressStates
)
const runningNodes = nodeProgressEntries.filter(
([_, state]) => state.state === 'running'
)
if (runningNodes.length === 0) {
return ''
}
// If multiple nodes are running
if (runningNodes.length > 1) {
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
})
const workflowTitle = computed(
() =>

View File

@@ -32,6 +32,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -170,11 +171,20 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
const settingStore = useSettingStore()
if (
!settingStore.get('Comfy.ComfirmClear') ||
!settingStore.get('Comfy.ConfirmClear') ||
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
if (app.canvas.subgraph) {
// `clear` is not implemented on subgraphs and the parent class's
// (`LGraph`) `clear` breaks the subgraph structure. For subgraphs,
// just clear the nodes but preserve input/output nodes and structure
const subgraph = app.canvas.subgraph
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
nonIoNodes.forEach((node) => subgraph.remove(node))
} else {
app.graph.clear()
}
api.dispatchCustomEvent('graphCleared')
}
}
@@ -316,6 +326,19 @@ export function useCoreCommands(): ComfyCommand[] {
}
})()
},
{
id: 'Comfy.Canvas.ToggleMinimap',
icon: 'pi pi-map',
label: 'Canvas Toggle Minimap',
versionAdded: '1.24.1',
function: async () => {
const settingStore = useSettingStore()
await settingStore.set(
'Comfy.Minimap.Visible',
!settingStore.get('Comfy.Minimap.Visible')
)
}
},
{
id: 'Comfy.QueuePrompt',
icon: 'pi pi-play',

View File

@@ -0,0 +1,94 @@
import { whenever } from '@vueuse/core'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToastStore } from '@/stores/toastStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
export interface UseFrontendVersionMismatchWarningOptions {
immediate?: boolean
}
/**
* Composable for handling frontend version mismatch warnings.
*
* Displays toast notifications when the frontend version is incompatible with the backend,
* either because the frontend is outdated or newer than the backend expects.
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
*
* @param options - Configuration options
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
* @returns Object with methods and computed properties for managing version warnings
*
* @example
* ```ts
* // Show warning immediately when mismatch detected
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
*
* // Manual control
* const { showWarning } = useFrontendVersionMismatchWarning()
* showWarning() // Call when needed
* ```
*/
export function useFrontendVersionMismatchWarning(
options: UseFrontendVersionMismatchWarningOptions = {}
) {
const { immediate = false } = options
const { t } = useI18n()
const toastStore = useToastStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
// Track if we've already shown the warning
let hasShownWarning = false
const showWarning = () => {
// Prevent showing the warning multiple times
if (hasShownWarning) return
const message = versionCompatibilityStore.warningMessage
if (!message) return
const detailMessage = t('g.frontendOutdated', {
frontendVersion: message.frontendVersion,
requiredVersion: message.requiredVersion
})
const fullMessage = t('g.versionMismatchWarningMessage', {
warning: t('g.versionMismatchWarning'),
detail: detailMessage
})
toastStore.addAlert(fullMessage)
hasShownWarning = true
// Automatically dismiss the warning so it won't show again for 7 days
versionCompatibilityStore.dismissWarning()
}
onMounted(() => {
// Only set up the watcher if immediate is true
if (immediate) {
whenever(
() => versionCompatibilityStore.shouldShowWarning,
() => {
showWarning()
},
{
immediate: true,
once: true
}
)
}
})
return {
showWarning,
shouldShowWarning: computed(
() => versionCompatibilityStore.shouldShowWarning
),
dismissWarning: versionCompatibilityStore.dismissWarning,
hasVersionMismatch: computed(
() => versionCompatibilityStore.hasVersionMismatch
)
}
}

View File

@@ -124,9 +124,12 @@ export const useLitegraphSettings = () => {
})
watchEffect(() => {
LiteGraph.macTrackpadGestures = settingStore.get(
'LiteGraph.Pointer.TrackpadGestures'
)
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
| 'standard'
| 'legacy'
LiteGraph.canvasNavigationMode = navigationMode
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
})
watchEffect(() => {

View File

@@ -0,0 +1,685 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useRafFn, useThrottleFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
}
export function useMinimap() {
const settingStore = useSettingStore()
const canvasStore = useCanvasStore()
const containerRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const minimapRef = ref<any>(null)
const visible = ref(true)
const initialized = ref(false)
const bounds = ref({
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
width: 0,
height: 0
})
const scale = ref(1)
const isDragging = ref(false)
const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 })
const needsFullRedraw = ref(true)
const needsBoundsUpdate = ref(true)
const lastNodeCount = ref(0)
const nodeStatesCache = new Map<NodeId, string>()
const linksCache = ref<string>('')
const updateFlags = ref({
bounds: false,
nodes: false,
connections: false,
viewport: false
})
const width = 250
const height = 200
const nodeColor = '#0B8CE999'
const linkColor = '#F99614'
const slotColor = '#F99614'
const viewportColor = '#FFF'
const backgroundColor = '#15161C'
const borderColor = '#333'
const containerRect = ref({
left: 0,
top: 0,
width: width,
height: height
})
const canvasDimensions = ref({
width: 0,
height: 0
})
const updateContainerRect = () => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
containerRect.value = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
}
}
const updateCanvasDimensions = () => {
const c = canvas.value
if (!c) return
const canvasEl = c.canvas
const dpr = window.devicePixelRatio || 1
canvasDimensions.value = {
width: canvasEl.clientWidth || canvasEl.width / dpr,
height: canvasEl.clientHeight || canvasEl.height / dpr
}
}
const canvas = computed(() => canvasStore.canvas)
const graph = computed(() => canvas.value?.graph)
const containerStyles = computed(() => ({
width: `${width}px`,
height: `${height}px`,
backgroundColor: backgroundColor,
border: `1px solid ${borderColor}`,
borderRadius: '8px'
}))
const viewportStyles = computed(() => ({
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
width: `${viewportTransform.value.width}px`,
height: `${viewportTransform.value.height}px`,
border: `2px solid ${viewportColor}`,
backgroundColor: `${viewportColor}33`,
willChange: 'transform',
backfaceVisibility: 'hidden' as const,
perspective: '1000px',
pointerEvents: 'none' as const
}))
const calculateGraphBounds = () => {
const g = graph.value
if (!g?._nodes || g._nodes.length === 0) {
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of g._nodes) {
minX = Math.min(minX, node.pos[0])
minY = Math.min(minY, node.pos[1])
maxX = Math.max(maxX, node.pos[0] + node.size[0])
maxY = Math.max(maxY, node.pos[1] + node.size[1])
}
let currentWidth = maxX - minX
let currentHeight = maxY - minY
// Enforce minimum viewport dimensions for better visualization
const minViewportWidth = 2500
const minViewportHeight = 2000
if (currentWidth < minViewportWidth) {
const padding = (minViewportWidth - currentWidth) / 2
minX -= padding
maxX += padding
currentWidth = minViewportWidth
}
if (currentHeight < minViewportHeight) {
const padding = (minViewportHeight - currentHeight) / 2
minY -= padding
maxY += padding
currentHeight = minViewportHeight
}
return {
minX,
minY,
maxX,
maxY,
width: currentWidth,
height: currentHeight
}
}
const calculateScale = () => {
if (bounds.value.width === 0 || bounds.value.height === 0) {
return 1
}
const scaleX = width / bounds.value.width
const scaleY = height / bounds.value.height
// Apply 0.9 factor to provide padding/gap between nodes and minimap borders
return Math.min(scaleX, scaleY) * 0.9
}
const renderNodes = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g || !g._nodes || g._nodes.length === 0) return
for (const node of g._nodes) {
const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
const w = node.size[0] * scale.value
const h = node.size[1] * scale.value
// Render solid node blocks
ctx.fillStyle = nodeColor
ctx.fillRect(x, y, w, h)
}
}
const renderConnections = (
ctx: CanvasRenderingContext2D,
offsetX: number,
offsetY: number
) => {
const g = graph.value
if (!g) return
ctx.strokeStyle = linkColor
ctx.lineWidth = 1.4
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
const connections: Array<{
x1: number
y1: number
x2: number
y2: number
}> = []
for (const node of g._nodes) {
if (!node.outputs) continue
const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
for (const output of node.outputs) {
if (!output.links) continue
for (const linkId of output.links) {
const link = g.links[linkId]
if (!link) continue
const targetNode = g.getNodeById(link.target_id)
if (!targetNode) continue
const x2 =
(targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX
const y2 =
(targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY
const outputX = x1 + node.size[0] * scale.value
const outputY = y1 + node.size[1] * scale.value * 0.2
const inputX = x2
const inputY = y2 + targetNode.size[1] * scale.value * 0.2
// Draw connection line
ctx.beginPath()
ctx.moveTo(outputX, outputY)
ctx.lineTo(inputX, inputY)
ctx.stroke()
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
}
}
}
// Render connection slots on top
ctx.fillStyle = slotColor
for (const conn of connections) {
// Output slot
ctx.beginPath()
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
ctx.fill()
// Input slot
ctx.beginPath()
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
ctx.fill()
}
}
const renderMinimap = () => {
if (!canvasRef.value || !graph.value) return
const ctx = canvasRef.value.getContext('2d')
if (!ctx) return
// Fast path for 0 nodes - just show background
if (!graph.value._nodes || graph.value._nodes.length === 0) {
ctx.clearRect(0, 0, width, height)
return
}
const needsRedraw =
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
if (needsRedraw) {
ctx.clearRect(0, 0, width, height)
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
renderNodes(ctx, offsetX, offsetY)
renderConnections(ctx, offsetX, offsetY)
needsFullRedraw.value = false
updateFlags.value.nodes = false
updateFlags.value.connections = false
}
}
const updateViewport = () => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
const worldX = -ds.offset[0]
const worldY = -ds.offset[1]
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
updateFlags.value.viewport = false
}
const updateMinimap = () => {
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
bounds.value = calculateGraphBounds()
scale.value = calculateScale()
needsBoundsUpdate.value = false
updateFlags.value.bounds = false
needsFullRedraw.value = true
}
if (
needsFullRedraw.value ||
updateFlags.value.nodes ||
updateFlags.value.connections
) {
renderMinimap()
}
}
const checkForChanges = useThrottleFn(() => {
const g = graph.value
if (!g) return
let structureChanged = false
let positionChanged = false
let connectionChanged = false
if (g._nodes.length !== lastNodeCount.value) {
structureChanged = true
lastNodeCount.value = g._nodes.length
}
for (const node of g._nodes) {
const key = node.id
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
if (nodeStatesCache.get(key) !== currentState) {
positionChanged = true
nodeStatesCache.set(key, currentState)
}
}
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
linksCache.value = currentLinks
}
const currentNodeIds = new Set(g._nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
structureChanged = true
}
}
if (structureChanged || positionChanged) {
updateFlags.value.bounds = true
updateFlags.value.nodes = true
}
if (connectionChanged) {
updateFlags.value.connections = true
}
if (structureChanged || positionChanged || connectionChanged) {
updateMinimap()
}
}, 500)
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
useRafFn(
async () => {
if (visible.value) {
await checkForChanges()
}
},
{ immediate: false }
)
const { startSync: startViewportSync, stopSync: stopViewportSync } =
useCanvasTransformSync(updateViewport, { autoStart: false })
const handleMouseDown = (e: MouseEvent) => {
isDragging.value = true
updateContainerRect()
handleMouseMove(e)
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value || !canvasRef.value || !canvas.value) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
centerViewOn(worldX, worldY)
}
const handleMouseUp = () => {
isDragging.value = false
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const c = canvas.value
if (!c) return
if (
containerRect.value.left === 0 &&
containerRect.value.top === 0 &&
containerRef.value
) {
updateContainerRect()
}
const ds = c.ds
const delta = e.deltaY > 0 ? 0.9 : 1.1
const newScale = ds.scale * delta
const MIN_SCALE = 0.1
const MAX_SCALE = 10
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
const x = e.clientX - containerRect.value.left
const y = e.clientY - containerRect.value.top
const offsetX = (width - bounds.value.width * scale.value) / 2
const offsetY = (height - bounds.value.height * scale.value) / 2
const worldX = (x - offsetX) / scale.value + bounds.value.minX
const worldY = (y - offsetY) / scale.value + bounds.value.minY
ds.scale = newScale
centerViewOn(worldX, worldY)
}
const centerViewOn = (worldX: number, worldY: number) => {
const c = canvas.value
if (!c) return
if (
canvasDimensions.value.width === 0 ||
canvasDimensions.value.height === 0
) {
updateCanvasDimensions()
}
const ds = c.ds
const viewportWidth = canvasDimensions.value.width / ds.scale
const viewportHeight = canvasDimensions.value.height / ds.scale
ds.offset[0] = -(worldX - viewportWidth / 2)
ds.offset[1] = -(worldY - viewportHeight / 2)
updateFlags.value.viewport = true
c.setDirty(true, true)
}
let originalCallbacks: GraphCallbacks = {}
const handleGraphChanged = useThrottleFn(() => {
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateMinimap()
}, 500)
const setupEventListeners = () => {
const g = graph.value
if (!g) return
originalCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
}
g.onNodeAdded = function (node) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChanged()
}
g.onNodeRemoved = function (node) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChanged()
}
g.onConnectionChange = function (node) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChanged()
}
}
const cleanupEventListeners = () => {
const g = graph.value
if (!g) return
if (originalCallbacks.onNodeAdded !== undefined) {
g.onNodeAdded = originalCallbacks.onNodeAdded
}
if (originalCallbacks.onNodeRemoved !== undefined) {
g.onNodeRemoved = originalCallbacks.onNodeRemoved
}
if (originalCallbacks.onConnectionChange !== undefined) {
g.onConnectionChange = originalCallbacks.onConnectionChange
}
}
const init = async () => {
if (initialized.value) return
visible.value = settingStore.get('Comfy.Minimap.Visible')
if (canvas.value && graph.value) {
setupEventListeners()
api.addEventListener('graphChanged', handleGraphChanged)
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
window.addEventListener('resize', updateContainerRect)
window.addEventListener('scroll', updateContainerRect)
window.addEventListener('resize', updateCanvasDimensions)
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
updateMinimap()
updateViewport()
if (visible.value) {
resumeChangeDetection()
startViewportSync()
}
initialized.value = true
}
}
const destroy = () => {
pauseChangeDetection()
stopViewportSync()
cleanupEventListeners()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
nodeStatesCache.clear()
initialized.value = false
}
watch(
canvas,
async (newCanvas, oldCanvas) => {
if (oldCanvas) {
cleanupEventListeners()
pauseChangeDetection()
stopViewportSync()
api.removeEventListener('graphChanged', handleGraphChanged)
window.removeEventListener('resize', updateContainerRect)
window.removeEventListener('scroll', updateContainerRect)
window.removeEventListener('resize', updateCanvasDimensions)
}
if (newCanvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
watch(visible, async (isVisible) => {
if (isVisible) {
if (containerRef.value) {
updateContainerRect()
}
updateCanvasDimensions()
needsFullRedraw.value = true
updateFlags.value.bounds = true
updateFlags.value.nodes = true
updateFlags.value.connections = true
updateFlags.value.viewport = true
await nextTick()
updateMinimap()
updateViewport()
resumeChangeDetection()
startViewportSync()
} else {
pauseChangeDetection()
stopViewportSync()
}
})
const toggle = async () => {
visible.value = !visible.value
await settingStore.set('Comfy.Minimap.Visible', visible.value)
}
const setMinimapRef = (ref: any) => {
minimapRef.value = ref
}
return {
visible: computed(() => visible.value),
initialized: computed(() => initialized.value),
containerRef,
canvasRef,
containerStyles,
viewportStyles,
width,
height,
init,
destroy,
toggle,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleWheel,
setMinimapRef
}
}

View File

@@ -1,5 +1,4 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { computed, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/stores/graphStore'
@@ -9,12 +8,11 @@ interface RefreshableItem {
refresh: () => Promise<void> | void
}
type RefreshableWidget = IBaseWidget & RefreshableItem
const isRefreshableWidget = (
widget: IBaseWidget
): widget is RefreshableWidget =>
'refresh' in widget && typeof widget.refresh === 'function'
const isRefreshableWidget = (widget: unknown): widget is RefreshableItem =>
widget != null &&
typeof widget === 'object' &&
'refresh' in widget &&
typeof widget.refresh === 'function'
/**
* Tracks selected nodes and their refreshable widgets
@@ -27,10 +25,17 @@ export const useRefreshableSelection = () => {
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
})
const refreshableWidgets = computed(() =>
selectedNodes.value.flatMap(
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
)
const refreshableWidgets = computed<RefreshableItem[]>(() =>
selectedNodes.value.flatMap((node) => {
if (!node.widgets) return []
const items: RefreshableItem[] = []
for (const widget of node.widgets) {
if (isRefreshableWidget(widget)) {
items.push(widget)
}
}
return items
})
)
const isRefreshable = computed(() => refreshableWidgets.value.length > 0)

View File

@@ -3,14 +3,23 @@ import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
@@ -20,7 +29,7 @@ export const useChatHistoryWidget = (
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
InstanceType<typeof ChatHistoryWidget>['$props']
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,

View File

@@ -3,9 +3,18 @@ import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
@@ -18,11 +27,17 @@ export const useTextPreviewWidget = (
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<string | object>({
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {

View File

@@ -8,6 +8,8 @@ import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
function addMultilineWidget(
node: LGraphNode,
name: string,
@@ -54,38 +56,55 @@ function addMultilineWidget(
}
})
/** Timer reference. `null` when the timer completes. */
let ignoreEventsTimer: ReturnType<typeof setTimeout> | null = null
/** Total number of events ignored since the timer started. */
let ignoredEvents = 0
// Pass wheel events to the canvas when appropriate
inputEl.addEventListener('wheel', (event: WheelEvent) => {
if (!Object.is(event.deltaX, -0)) return
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
// If the textarea has focus, require more effort to activate pass-through
const multiplier = document.activeElement === inputEl ? 2 : 1
const maxScrollHeight = inputEl.scrollHeight - inputEl.clientHeight
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
if (
(event.deltaY < 0 && inputEl.scrollTop === 0) ||
(event.deltaY > 0 && inputEl.scrollTop === maxScrollHeight)
) {
// Attempting to scroll past the end of the textarea
if (!ignoreEventsTimer || ignoredEvents > 25 * multiplier) {
app.canvas.processMouseWheel(event)
} else {
ignoredEvents++
}
} else if (event.deltaY !== 0) {
// Start timer whenever a successful scroll occurs
ignoredEvents = 0
if (ignoreEventsTimer) clearTimeout(ignoreEventsTimer)
ignoreEventsTimer = setTimeout(() => {
ignoreEventsTimer = null
}, 800 * multiplier)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget

View File

@@ -1,3 +1,3 @@
{
"supports_preview_metadata": false
"supports_preview_metadata": true
}

View File

@@ -68,12 +68,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.OpenWorkflow'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.ClearWorkflow'
},
{
combo: {
key: 'g',
@@ -181,5 +175,12 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
},
{
combo: {
key: 'm',
alt: true
},
commandId: 'Comfy.Canvas.ToggleMinimap'
}
]

View File

@@ -42,7 +42,10 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
}
},
{
id: 'Comfy.LinkRelease.ActionShift',
@@ -50,7 +53,10 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
defaultsByInstallVersion: {
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
}
},
{
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
@@ -512,15 +518,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: [] as string[],
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'
},
{
id: 'Comfy.LinkRenderMode',
category: ['LiteGraph', 'Graph', 'LinkRenderMode'],
@@ -792,6 +789,21 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 0.6,
versionAdded: '1.9.1'
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas', 'CanvasNavigationMode'],
name: 'Canvas Navigation Mode',
defaultValue: 'legacy',
type: 'combo',
options: [
{ value: 'standard', text: 'Standard (New)' },
{ value: 'legacy', text: 'Left-Click Pan (Legacy)' }
],
versionAdded: '1.25.0',
defaultsByInstallVersion: {
'1.25.0': 'standard'
}
},
{
id: 'Comfy.Canvas.SelectionToolbox',
category: ['LiteGraph', 'Canvas', 'SelectionToolbox'],
@@ -819,6 +831,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.15.12'
},
{
id: 'Comfy.Minimap.Visible',
name: 'Display minimap on canvas',
type: 'hidden',
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.Workflow.AutoSaveDelay',
name: 'Auto Save Delay (ms)',
@@ -862,17 +881,6 @@ export const CORE_SETTINGS: SettingParams[] = [
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'LiteGraph.Pointer.TrackpadGestures',
category: ['LiteGraph', 'Pointer', 'Trackpad Gestures'],
experimental: true,
name: 'Enable trackpad gestures',
tooltip:
'This setting enables trackpad mode for the canvas, allowing pinch-to-zoom and panning with two fingers.',
type: 'boolean',
defaultValue: false,
versionAdded: '1.19.1'
},
// Release data stored in settings
{
id: 'Comfy.Release.Version',

View File

@@ -6,8 +6,8 @@
* All supported image formats that can contain workflow data
*/
export const IMAGE_WORKFLOW_FORMATS = {
extensions: ['.png', '.webp', '.svg'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml']
extensions: ['.png', '.webp', '.svg', '.avif'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif']
}
/**

View File

@@ -15,6 +15,7 @@ import {
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
@@ -1224,9 +1225,10 @@ export class GroupNodeHandler {
node.onDrawForeground = function (ctx) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
// @ts-expect-error fixme ts strict error
+app.runningNodeId === this.id &&
progressState &&
progressState.state === 'running' &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
@@ -1340,6 +1342,7 @@ export class GroupNodeHandler {
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}

View File

@@ -0,0 +1,29 @@
export interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
export const imageLayerFilenamesByTimestamp = (
timestamp: number
): ImageLayerFilenames => ({
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
})
export const imageLayerFilenamesIfApplicable = (
inputImageFilename: string
): ImageLayerFilenames | undefined => {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return imageLayerFilenamesByTimestamp(timestamp)
}

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ app.registerExtension({
// @ts-expect-error fixme ts strict error
widget.serializeValue = () => {
// @ts-expect-error fixme ts strict error
return applyTextReplacements(app.graph.nodes, widget.value)
return applyTextReplacements(app.graph, widget.value)
}
return r

View File

@@ -1,6 +1,11 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
import type {
IBaseWidget,
IStringWidget
} from '@comfyorg/litegraph/dist/types/widgets'
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
@@ -8,7 +13,10 @@ import { t } from '@/i18n'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { useToastStore } from '@/stores/toastStore'
import { NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
@@ -137,14 +145,27 @@ app.registerExtension({
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
return { widget: audioUIWidget }
}
}
},
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
const node = app.graph.getNodeById(nodeId)
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
@@ -241,3 +262,167 @@ app.registerExtension({
}
}
})
app.registerExtension({
name: 'Comfy.RecordAudio',
getCustomWidgets() {
return {
AUDIO_RECORD(node, inputName: string) {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
let audioChunks: Blob[] = []
let currentStream: MediaStream | null = null
let recordWidget: IBaseWidget | null = null
let stopPromise: Promise<void> | null = null
let stopResolve: (() => void) | null = null
audioUIWidget.serializeValue = async () => {
if (isRecording && mediaRecorder) {
stopPromise = new Promise((resolve) => {
stopResolve = resolve
})
mediaRecorder.stop()
await stopPromise
}
const audioSrc = audioUIWidget.element.src
if (!audioSrc) {
useToastStore().addAlert(t('g.noAudioRecorded'))
return ''
}
const blob = await fetch(audioSrc).then((r) => r.blob())
return await useAudioService().convertBlobToFileAndSubmit(blob)
}
recordWidget = node.addWidget(
'button',
inputName,
'',
async () => {
if (!isRecording) {
try {
currentStream = await navigator.mediaDevices.getUserMedia({
audio: true
})
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
mimeType: 'audio/wav'
}) as unknown as MediaRecorder
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
useAudioService().stopAllTracks(currentStream)
if (
audioUIWidget.element.src &&
audioUIWidget.element.src.startsWith('blob:')
) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.onerror = (event) => {
console.error('MediaRecorder error:', event)
useAudioService().stopAllTracks(currentStream)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.start()
isRecording = true
if (recordWidget) {
recordWidget.label = t('g.stopRecording')
}
} catch (err) {
console.error('Error accessing microphone:', err)
useToastStore().addAlert(t('g.micPermissionDenied'))
if (mediaRecorder) {
try {
mediaRecorder.stop()
} catch {}
}
useAudioService().stopAllTracks(currentStream)
currentStream = null
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
}
} else if (mediaRecorder && isRecording) {
mediaRecorder.stop()
}
},
{ serialize: false }
)
recordWidget.label = t('g.startRecording')
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {
if (isRecording && mediaRecorder) {
mediaRecorder.stop()
}
useAudioService().stopAllTracks(currentStream)
if (audioUIWidget.element.src?.startsWith('blob:')) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
originalOnRemoved?.call(this)
}
return { widget: recordWidget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'RecordAudio') return
await useAudioService().registerWavEncoder()
}
})

View File

@@ -46,7 +46,7 @@ export class PrimitiveNode extends LGraphNode {
]
let v = this.widgets?.[0].value
if (v && this.properties[replacePropertyName]) {
v = applyTextReplacements(app.graph.nodes, v as string)
v = applyTextReplacements(app.graph, v as string)
}
// For each output link copy our value over the original widget value

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Canvas Toggle Lock"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Canvas Toggle Minimap"
},
"Comfy_Canvas_ToggleSelected_Pin": {
"label": "Pin/Unpin Selected Items"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle the Custom Nodes Manager Progress Bar"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Decrease Brush Size in MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Increase Brush Size in MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},

View File

@@ -98,6 +98,12 @@
"nodes": "Nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
"versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.",
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss",
"update": "Update",
"updated": "Updated",
"resultsCount": "Found {count} Results",
@@ -135,6 +141,11 @@
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on",
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information."
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",
"micPermissionDenied": "Microphone permission denied",
"noAudioRecorded": "No audio recorded",
"nodesRunning": "nodes running"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -423,7 +434,6 @@
"restart": "Restart"
},
"sideToolbar": {
"themeToggle": "Toggle Theme",
"helpCenter": "Help Center",
"logout": "Logout",
"queue": "Queue",
@@ -524,7 +534,13 @@
"clipspace": "Open Clipspace",
"resetView": "Reset canvas view",
"clear": "Clear workflow",
"toggleBottomPanel": "Toggle Bottom Panel"
"toggleBottomPanel": "Toggle Bottom Panel",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"manageExtensions": "Manage Extensions",
"settings": "Settings",
"help": "Help"
},
"tabMenu": {
"duplicateTab": "Duplicate Tab",
@@ -866,7 +882,8 @@
"fitView": "Fit View",
"selectMode": "Select Mode",
"panMode": "Pan Mode",
"toggleLinkVisibility": "Toggle Link Visibility"
"toggleLinkVisibility": "Toggle Link Visibility",
"toggleMinimap": "Toggle Minimap"
},
"groupNode": {
"create": "Create group node",
@@ -943,6 +960,7 @@
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
"Canvas Toggle Lock": "Canvas Toggle Lock",
"Canvas Toggle Minimap": "Canvas Toggle Minimap",
"Pin/Unpin Selected Items": "Pin/Unpin Selected Items",
"Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes",
"Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes",
@@ -976,6 +994,8 @@
"Install Missing Custom Nodes": "Install Missing Custom Nodes",
"Check for Custom Node Updates": "Check for Custom Node Updates",
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
@@ -1348,6 +1368,13 @@
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"versionMismatchWarning": {
"title": "Version Compatibility Warning",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires version {requiredVersion} or higher.",
"frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.",
"updateFrontend": "Update Frontend",
"dismiss": "Dismiss"
},
"errorDialog": {
"defaultTitle": "An error occurred",
"loadWorkflowTitle": "Loading aborted due to error reloading workflow data",
@@ -1605,5 +1632,11 @@
"whatsNewPopup": {
"learnMore": "Learn more",
"noReleaseNotes": "No release notes available."
},
"breadcrumbsMenu": {
"duplicate": "Duplicate",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"enterNewName": "Enter new name"
}
}

View File

@@ -29,6 +29,13 @@
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_NavigationMode": {
"name": "Canvas Navigation Mode",
"options": {
"Standard (New)": "Standard (New)",
"Left-Click Pan (Legacy)": "Left-Click Pan (Legacy)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Show selection toolbox"
},
@@ -329,10 +336,6 @@
"Bottom": "Bottom"
}
},
"Comfy_Validation_NodeDefs": {
"name": "Validate node definitions (slow)",
"tooltip": "Recommended for node developers. This will validate all node definitions on startup."
},
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Tooltip Delay"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "Enable trackpad gestures",
"tooltip": "This setting enables trackpad mode for the canvas, allowing pinch-to-zoom and panning with two fingers."
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Reroute spline offset",
"tooltip": "The bezier control point offset from the reroute centre point"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Alternar bloqueo en lienzo"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Lienzo Alternar Minimapa"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Omitir/No omitir nodos seleccionados"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Alternar diálogo de progreso del administrador"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Disminuir tamaño del pincel en MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Aumentar tamaño del pincel en MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Abrir editor de máscara para el nodo seleccionado"
},

View File

@@ -82,6 +82,12 @@
"title": "Crea una cuenta"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Limpiar flujo de trabajo",
"deleteWorkflow": "Eliminar flujo de trabajo",
"duplicate": "Duplicar",
"enterNewName": "Ingrese un nuevo nombre"
},
"chatHistory": {
"cancelEdit": "Cancelar",
"cancelEditTooltip": "Cancelar edición",
@@ -291,6 +297,7 @@
"devices": "Dispositivos",
"disableAll": "Deshabilitar todo",
"disabling": "Deshabilitando",
"dismiss": "Descartar",
"download": "Descargar",
"edit": "Editar",
"empty": "Vacío",
@@ -305,6 +312,8 @@
"filter": "Filtrar",
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
@@ -326,17 +335,20 @@
"loadingPanel": "Cargando panel {panel}...",
"login": "Iniciar sesión",
"logs": "Registros",
"micPermissionDenied": "Permiso de micrófono denegado",
"migrate": "Migrar",
"missing": "Faltante",
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",
"no": "No",
"noAudioRecorded": "No se grabó audio",
"noResultsFound": "No se encontraron resultados",
"noTasksFound": "No se encontraron tareas",
"noTasksFoundMessage": "No hay tareas en la cola.",
"noWorkflowsFound": "No se encontraron flujos de trabajo.",
"nodes": "Nodos",
"nodesRunning": "nodos en ejecución",
"ok": "OK",
"openNewIssue": "Abrir nuevo problema",
"overwrite": "Sobrescribir",
@@ -370,7 +382,9 @@
"showReport": "Mostrar informe",
"sort": "Ordenar",
"source": "Fuente",
"startRecording": "Iniciar grabación",
"status": "Estado",
"stopRecording": "Detener grabación",
"success": "Éxito",
"systemInfo": "Información del sistema",
"terminal": "Terminal",
@@ -379,11 +393,14 @@
"unknownError": "Error desconocido",
"update": "Actualizar",
"updateAvailable": "Actualización Disponible",
"updateFrontend": "Actualizar frontend",
"updated": "Actualizado",
"updating": "Actualizando",
"upload": "Subir",
"usageHint": "Sugerencia de uso",
"user": "Usuario",
"versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video",
"workflow": "Flujo de trabajo"
},
@@ -393,6 +410,7 @@
"resetView": "Restablecer vista",
"selectMode": "Modo de selección",
"toggleLinkVisibility": "Alternar visibilidad de enlace",
"toggleMinimap": "Alternar minimapa",
"zoomIn": "Acercar",
"zoomOut": "Alejar"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "El número de veces que la generación del flujo de trabajo debe ser encolada",
"clear": "Limpiar flujo de trabajo",
"clipspace": "Abrir Clipspace",
"dark": "Oscuro",
"disabled": "Deshabilitado",
"disabledTooltip": "El flujo de trabajo no se encolará automáticamente",
"execute": "Ejecutar",
"help": "Ayuda",
"hideMenu": "Ocultar menú",
"instant": "Instantáneo",
"instantTooltip": "El flujo de trabajo se encolará instantáneamente después de que finalice una generación",
"interrupt": "Cancelar ejecución actual",
"light": "Claro",
"manageExtensions": "Gestionar extensiones",
"onChange": "Al cambiar",
"onChangeTooltip": "El flujo de trabajo se encolará una vez que se haga un cambio",
"refresh": "Actualizar definiciones de nodos",
@@ -715,7 +737,9 @@
"run": "Ejecutar",
"runWorkflow": "Ejecutar flujo de trabajo (Shift para encolar al frente)",
"runWorkflowFront": "Ejecutar flujo de trabajo (Encolar al frente)",
"settings": "Configuración",
"showMenu": "Mostrar menú",
"theme": "Tema",
"toggleBottomPanel": "Alternar panel inferior"
},
"menuLabels": {
@@ -726,6 +750,7 @@
"Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo",
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
"Canvas Toggle Minimap": "Lienzo: Alternar minimapa",
"Check for Updates": "Buscar actualizaciones",
"Clear Pending Tasks": "Borrar tareas pendientes",
"Clear Workflow": "Borrar flujo de trabajo",
@@ -741,6 +766,7 @@
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes (Legacy)": "Nodos personalizados (heredado)",
"Custom Nodes Manager": "Administrador de Nodos Personalizados",
"Decrease Brush Size in MaskEditor": "Disminuir tamaño del pincel en MaskEditor",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
@@ -753,6 +779,7 @@
"Group Selected Nodes": "Agrupar nodos seleccionados",
"Help": "Ayuda",
"Install Missing Custom Nodes": "Instalar nodos personalizados faltantes",
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
@@ -1169,7 +1196,6 @@
},
"showFlatList": "Mostrar lista plana"
},
"themeToggle": "Cambiar tema",
"workflowTab": {
"confirmDelete": "¿Estás seguro de que quieres eliminar este flujo de trabajo?",
"confirmDeleteTitle": "¿Eliminar flujo de trabajo?",
@@ -1593,6 +1619,13 @@
"prefix": "Debe comenzar con {prefix}",
"required": "Requerido"
},
"versionMismatchWarning": {
"dismiss": "Descartar",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"title": "Advertencia de compatibilidad de versión",
"updateFrontend": "Actualizar frontend"
},
"welcome": {
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"

View File

@@ -29,6 +29,13 @@
"name": "Imagen de fondo del lienzo",
"tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla."
},
"Comfy_Canvas_NavigationMode": {
"name": "Modo de navegación del lienzo",
"options": {
"Left-Click Pan (Legacy)": "Desplazamiento con clic izquierdo (Legado)",
"Standard (New)": "Estándar (Nuevo)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Mostrar caja de herramientas de selección"
},
@@ -329,10 +336,6 @@
},
"tooltip": "Posición de la barra de menú. En dispositivos móviles, el menú siempre se muestra en la parte superior."
},
"Comfy_Validation_NodeDefs": {
"name": "Validar definiciones de nodos (lento)",
"tooltip": "Recomendado para desarrolladores de nodos. Esto validará todas las definiciones de nodos al iniciar."
},
"Comfy_Validation_Workflows": {
"name": "Validar flujos de trabajo"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Retraso de la información sobre herramientas"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "Habilitar gestos del trackpad",
"tooltip": "Esta configuración activa el modo trackpad para el lienzo, permitiendo hacer zoom con pellizco y desplazar con dos dedos."
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Desvío de la compensación de la spline",
"tooltip": "El punto de control bezier desplazado desde el punto central de reenrutamiento"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Basculer le verrouillage du canevas"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Basculer la mini-carte du canevas"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Contourner/Ne pas contourner les nœuds sélectionnés"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Réduire la taille du pinceau dans MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Augmenter la taille du pinceau dans MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Ouvrir l'éditeur de masque pour le nœud sélectionné"
},

View File

@@ -82,6 +82,12 @@
"title": "Créer un compte"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Effacer le workflow",
"deleteWorkflow": "Supprimer le workflow",
"duplicate": "Dupliquer",
"enterNewName": "Entrez un nouveau nom"
},
"chatHistory": {
"cancelEdit": "Annuler",
"cancelEditTooltip": "Annuler la modification",
@@ -291,6 +297,7 @@
"devices": "Appareils",
"disableAll": "Désactiver tout",
"disabling": "Désactivation",
"dismiss": "Fermer",
"download": "Télécharger",
"edit": "Modifier",
"empty": "Vide",
@@ -305,6 +312,8 @@
"filter": "Filtrer",
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
@@ -326,17 +335,20 @@
"loadingPanel": "Chargement du panneau {panel}...",
"login": "Connexion",
"logs": "Journaux",
"micPermissionDenied": "Permission du microphone refusée",
"migrate": "Migrer",
"missing": "Manquant",
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",
"no": "Non",
"noAudioRecorded": "Aucun audio enregistré",
"noResultsFound": "Aucun résultat trouvé",
"noTasksFound": "Aucune tâche trouvée",
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
"noWorkflowsFound": "Aucun flux de travail trouvé.",
"nodes": "Nœuds",
"nodesRunning": "nœuds en cours dexécution",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",
@@ -370,7 +382,9 @@
"showReport": "Afficher le rapport",
"sort": "Trier",
"source": "Source",
"startRecording": "Commencer lenregistrement",
"status": "Statut",
"stopRecording": "Arrêter lenregistrement",
"success": "Succès",
"systemInfo": "Informations système",
"terminal": "Terminal",
@@ -379,11 +393,14 @@
"unknownError": "Erreur inconnue",
"update": "Mettre à jour",
"updateAvailable": "Mise à jour disponible",
"updateFrontend": "Mettre à jour le frontend",
"updated": "Mis à jour",
"updating": "Mise à jour",
"upload": "Téléverser",
"usageHint": "Conseil d'utilisation",
"user": "Utilisateur",
"versionMismatchWarning": "Avertissement de compatibilité de version",
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
},
@@ -393,6 +410,7 @@
"resetView": "Réinitialiser la vue",
"selectMode": "Mode sélection",
"toggleLinkVisibility": "Basculer la visibilité des liens",
"toggleMinimap": "Afficher/Masquer la mini-carte",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "Le nombre de fois que la génération du flux de travail doit être mise en file d'attente",
"clear": "Effacer le flux de travail",
"clipspace": "Ouvrir Clipspace",
"dark": "Sombre",
"disabled": "Désactivé",
"disabledTooltip": "Le flux de travail ne sera pas mis en file d'attente automatiquement",
"execute": "Exécuter",
"help": "Aide",
"hideMenu": "Masquer le menu",
"instant": "Instantané",
"instantTooltip": "Le flux de travail sera mis en file d'attente immédiatement après la fin d'une génération",
"interrupt": "Annuler l'exécution en cours",
"light": "Clair",
"manageExtensions": "Gérer les extensions",
"onChange": "Sur modification",
"onChangeTooltip": "Le flux de travail sera mis en file d'attente une fois une modification effectuée",
"refresh": "Actualiser les définitions des nœuds",
@@ -715,7 +737,9 @@
"run": "Exécuter",
"runWorkflow": "Exécuter le workflow (Maj pour mettre en file d'attente en premier)",
"runWorkflowFront": "Exécuter le workflow (Mettre en file d'attente en premier)",
"settings": "Paramètres",
"showMenu": "Afficher le menu",
"theme": "Thème",
"toggleBottomPanel": "Basculer le panneau inférieur"
},
"menuLabels": {
@@ -727,6 +751,8 @@
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
"Check for Updates": "Vérifier les Mises à Jour",
"Canvas Toggle Minimap": "Basculer la mini-carte du canevas",
"Check for Updates": "Vérifier les mises à jour",
"Clear Pending Tasks": "Effacer les tâches en attente",
"Clear Workflow": "Effacer le flux de travail",
"Clipspace": "Espace de clip",
@@ -741,6 +767,7 @@
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes (Legacy)": "Nœuds personnalisés (héritage)",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Decrease Brush Size in MaskEditor": "Réduire la taille du pinceau dans MaskEditor",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
@@ -753,6 +780,7 @@
"Group Selected Nodes": "Grouper les nœuds sélectionnés",
"Help": "Aide",
"Install Missing Custom Nodes": "Installer les nœuds personnalisés manquants",
"Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor",
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
@@ -1169,7 +1197,6 @@
},
"showFlatList": "Afficher la liste plate"
},
"themeToggle": "Changer de thème",
"workflowTab": {
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce flux de travail ?",
"confirmDeleteTitle": "Supprimer le flux de travail ?",
@@ -1593,6 +1620,13 @@
"prefix": "Doit commencer par {prefix}",
"required": "Requis"
},
"versionMismatchWarning": {
"dismiss": "Ignorer",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend nécessite la version {requiredVersion} ou supérieure.",
"title": "Avertissement de compatibilité de version",
"updateFrontend": "Mettre à jour le frontend"
},
"welcome": {
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"

View File

@@ -29,6 +29,13 @@
"name": "Image de fond du canevas",
"tooltip": "URL de l'image pour le fond du canevas. Vous pouvez faire un clic droit sur une image dans le panneau de sortie et sélectionner « Définir comme fond » pour l'utiliser."
},
"Comfy_Canvas_NavigationMode": {
"name": "Mode de navigation sur le canvas",
"options": {
"Left-Click Pan (Legacy)": "Panoramique clic gauche (Hérité)",
"Standard (New)": "Standard (Nouveau)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Afficher la boîte à outils de sélection"
},
@@ -329,10 +336,6 @@
},
"tooltip": "Position de la barre de menu. Sur les appareils mobiles, le menu est toujours affiché en haut."
},
"Comfy_Validation_NodeDefs": {
"name": "Valider les définitions de nœuds (lent)",
"tooltip": "Recommandé pour les développeurs de nœuds. Cela validera toutes les définitions de nœuds au démarrage."
},
"Comfy_Validation_Workflows": {
"name": "Valider les flux de travail"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Délai d'infobulle"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "Activer les gestes du trackpad",
"tooltip": "Ce paramètre active le mode trackpad pour le canevas, permettant le zoom par pincement et le déplacement à deux doigts."
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Réacheminement décalage de spline",
"tooltip": "Le point de contrôle de Bézier est décalé par rapport au point central de réacheminement"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "キャンバスのロックを切り替える"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "キャンバス ミニマップ切り替え"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "選択したノードのバイパス/バイパス解除"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "マスクエディタでブラシサイズを縮小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "マスクエディタでブラシサイズを大きくする"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "選択したノードのマスクエディタを開く"
},

View File

@@ -82,6 +82,12 @@
"title": "アカウントを作成する"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "ワークフローをクリア",
"deleteWorkflow": "ワークフローを削除",
"duplicate": "複製",
"enterNewName": "新しい名前を入力"
},
"chatHistory": {
"cancelEdit": "キャンセル",
"cancelEditTooltip": "編集をキャンセル",
@@ -291,6 +297,7 @@
"devices": "デバイス",
"disableAll": "すべて無効にする",
"disabling": "無効化",
"dismiss": "閉じる",
"download": "ダウンロード",
"edit": "編集",
"empty": "空",
@@ -305,6 +312,8 @@
"filter": "フィルタ",
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"frontendNewer": "フロントエンドのバージョン {frontendVersion} はバックエンドのバージョン {backendVersion} と互換性がない可能性があります。",
"frontendOutdated": "フロントエンドのバージョン {frontendVersion} は古くなっています。バックエンドは {requiredVersion} 以上が必要です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
@@ -326,17 +335,20 @@
"loadingPanel": "{panel} パネルを読み込み中...",
"login": "ログイン",
"logs": "ログ",
"micPermissionDenied": "マイクの許可が拒否されました",
"migrate": "移行する",
"missing": "不足している",
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",
"no": "いいえ",
"noAudioRecorded": "音声が録音されていません",
"noResultsFound": "結果が見つかりません",
"noTasksFound": "タスクが見つかりません",
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"nodesRunning": "ノードが実行中",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",
@@ -370,7 +382,9 @@
"showReport": "レポートを表示",
"sort": "並び替え",
"source": "ソース",
"startRecording": "録音開始",
"status": "ステータス",
"stopRecording": "録音停止",
"success": "成功",
"systemInfo": "システム情報",
"terminal": "ターミナル",
@@ -379,11 +393,14 @@
"unknownError": "不明なエラー",
"update": "更新",
"updateAvailable": "更新が利用可能",
"updateFrontend": "フロントエンドを更新",
"updated": "更新済み",
"updating": "更新中",
"upload": "アップロード",
"usageHint": "使用ヒント",
"user": "ユーザー",
"versionMismatchWarning": "バージョン互換性の警告",
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
},
@@ -393,6 +410,7 @@
"resetView": "ビューをリセット",
"selectMode": "選択モード",
"toggleLinkVisibility": "リンクの表示切り替え",
"toggleMinimap": "ミニマップの切り替え",
"zoomIn": "拡大",
"zoomOut": "縮小"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "ワークフロー生成回数",
"clear": "ワークフローをクリア",
"clipspace": "クリップスペースを開く",
"dark": "ダーク",
"disabled": "無効",
"disabledTooltip": "ワークフローは自動的にキューに追加されません",
"execute": "実行",
"help": "ヘルプ",
"hideMenu": "メニューを隠す",
"instant": "即時",
"instantTooltip": "生成完了後すぐにキューに追加",
"interrupt": "現在の実行を中止",
"light": "ライト",
"manageExtensions": "拡張機能の管理",
"onChange": "変更時",
"onChangeTooltip": "変更が行われるとワークフローがキューに追加されます",
"refresh": "ノードを更新",
@@ -715,7 +737,9 @@
"run": "実行する",
"runWorkflow": "ワークフローを実行する (Shiftで先頭にキュー)",
"runWorkflowFront": "ワークフローを実行する (先頭にキュー)",
"settings": "設定",
"showMenu": "メニューを表示",
"theme": "テーマ",
"toggleBottomPanel": "下部パネルを切り替え"
},
"menuLabels": {
@@ -727,6 +751,8 @@
"Canvas Toggle Lock": "キャンバスのロックを切り替え",
"Check for Custom Node Updates": "カスタムノードのアップデートを確認",
"Check for Updates": "更新を確認",
"Canvas Toggle Minimap": "キャンバス ミニマップの切り替え",
"Check for Updates": "更新を確認する",
"Clear Pending Tasks": "保留中のタスクをクリア",
"Clear Workflow": "ワークフローをクリア",
"Clipspace": "クリップスペース",
@@ -741,6 +767,7 @@
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes (Legacy)": "カスタムノード(レガシー)",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Decrease Brush Size in MaskEditor": "マスクエディタでブラシサイズを小さくする",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
"Duplicate Current Workflow": "現在のワークフローを複製",
@@ -753,6 +780,7 @@
"Group Selected Nodes": "選択したノードをグループ化",
"Help": "ヘルプ",
"Install Missing Custom Nodes": "不足しているカスタムノードをインストール",
"Increase Brush Size in MaskEditor": "マスクエディタでブラシサイズを大きくする",
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
@@ -1169,7 +1197,6 @@
},
"showFlatList": "フラットリストを表示"
},
"themeToggle": "テーマの切り替え",
"workflowTab": {
"confirmDelete": "このワークフローを削除してもよろしいですか?",
"confirmDeleteTitle": "ワークフローを削除しますか?",
@@ -1593,6 +1620,13 @@
"prefix": "{prefix}で始める必要があります",
"required": "必須"
},
"versionMismatchWarning": {
"dismiss": "閉じる",
"frontendNewer": "フロントエンドのバージョン {frontendVersion} は、バックエンドのバージョン {backendVersion} と互換性がない可能性があります。",
"frontendOutdated": "フロントエンドのバージョン {frontendVersion} は古くなっています。バックエンドはバージョン {requiredVersion} 以上が必要です。",
"title": "バージョン互換性の警告",
"updateFrontend": "フロントエンドを更新"
},
"welcome": {
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"

View File

@@ -29,6 +29,13 @@
"name": "キャンバス背景画像",
"tooltip": "キャンバスの背景画像のURLです。出力パネルで画像を右クリックし、「背景として設定」を選択すると使用できます。"
},
"Comfy_Canvas_NavigationMode": {
"name": "キャンバスナビゲーションモード",
"options": {
"Left-Click Pan (Legacy)": "左クリックパン(レガシー)",
"Standard (New)": "標準(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "選択ツールボックスを表示"
},
@@ -329,10 +336,6 @@
},
"tooltip": "メニューバーの位置。モバイルデバイスでは、メニューは常に上部に表示されます。"
},
"Comfy_Validation_NodeDefs": {
"name": "ノード定義を検証(遅い)",
"tooltip": "ノード開発者に推奨されます。これにより、起動時にすべてのノード定義が検証されます。"
},
"Comfy_Validation_Workflows": {
"name": "ワークフローを検証"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "ツールチップ遅延"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "トラックパッドジェスチャーを有効にする",
"tooltip": "この設定を有効にすると、キャンバスでトラックパッドモードが有効になり、ピンチズームや2本指でのパン操作が可能になります。"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "リルートスプラインオフセット",
"tooltip": "リルート中心点からのベジエ制御点のオフセット"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "캔버스 잠금 토글"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "캔버스 미니맵 전환"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "선택한 노드 우회/우회 해제"
},
@@ -96,13 +99,13 @@
"label": "보류 중인 작업 지우기"
},
"Comfy_ClearWorkflow": {
"label": "워크플로 지우기"
"label": "워크플로 내용 지우기"
},
"Comfy_ContactSupport": {
"label": "지원팀에 문의하기"
},
"Comfy_DuplicateWorkflow": {
"label": "현재 워크플로 복제"
"label": "현재 워크플로 복제"
},
"Comfy_ExportWorkflow": {
"label": "워크플로 내보내기"
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "진행 상황 대화 상자 전환"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "마스크 편집기에서 브러시 크기 줄이기"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "마스크 편집기에서 브러시 크기 늘리기"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "선택한 노드 마스크 편집기 열기"
},
@@ -225,7 +234,7 @@
"label": "로그아웃"
},
"Workspace_CloseWorkflow": {
"label": "현재 워크플로 닫기"
"label": "현재 워크플로 닫기"
},
"Workspace_NextOpenedWorkflow": {
"label": "다음 열린 워크플로"

View File

@@ -5,7 +5,7 @@
"totalCost": "총 비용"
},
"apiNodesSignInDialog": {
"message": "이 워크플로에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
"message": "이 워크플로에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
"title": "API 노드 사용에 필요한 로그인"
},
"auth": {
@@ -82,6 +82,12 @@
"title": "계정 생성"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "워크플로 내용 지우기",
"deleteWorkflow": "워크플로 삭제",
"duplicate": "복제",
"enterNewName": "새 이름 입력"
},
"chatHistory": {
"cancelEdit": "취소",
"cancelEditTooltip": "편집 취소",
@@ -155,7 +161,7 @@
"time": "시간",
"topUp": {
"buyNow": "지금 구매",
"insufficientMessage": "이 워크플로를 실행하기에 크레딧이 부족합니다.",
"insufficientMessage": "이 워크플로를 실행하기에 크레딧이 부족합니다.",
"insufficientTitle": "크레딧 부족",
"maxAmount": "(최대 $1,000 USD)",
"quickPurchase": "빠른 구매",
@@ -246,7 +252,7 @@
"errorDialog": {
"defaultTitle": "오류가 발생했습니다",
"extensionFileHint": "다음 스크립트 때문일 수 있습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"loadWorkflowTitle": "워크플로 데이터를 다시 로드하는 중 오류로 인해 로드가 중단되었습니다",
"noStackTrace": "스택 추적을 사용할 수 없습니다",
"promptExecutionError": "프롬프트 실행 실패"
},
@@ -291,6 +297,7 @@
"devices": "장치",
"disableAll": "모두 비활성화",
"disabling": "비활성화 중",
"dismiss": "닫기",
"download": "다운로드",
"edit": "편집",
"empty": "비어 있음",
@@ -305,6 +312,8 @@
"filter": "필터",
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상이 필요합니다.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
@@ -326,17 +335,20 @@
"loadingPanel": "{panel} 패널 불러오는 중...",
"login": "로그인",
"logs": "로그",
"micPermissionDenied": "마이크 권한이 거부되었습니다",
"migrate": "이전(migrate)",
"missing": "누락됨",
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
"no": "아니오",
"noAudioRecorded": "녹음된 오디오가 없습니다",
"noResultsFound": "결과를 찾을 수 없습니다.",
"noTasksFound": "작업을 찾을 수 없습니다.",
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"nodesRunning": "노드 실행 중",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",
@@ -370,7 +382,9 @@
"showReport": "보고서 보기",
"sort": "정렬",
"source": "소스",
"startRecording": "녹음 시작",
"status": "상태",
"stopRecording": "녹음 중지",
"success": "성공",
"systemInfo": "시스템 정보",
"terminal": "터미널",
@@ -379,11 +393,14 @@
"unknownError": "알 수 없는 오류",
"update": "업데이트",
"updateAvailable": "업데이트 가능",
"updateFrontend": "프론트엔드 업데이트",
"updated": "업데이트 됨",
"updating": "업데이트 중",
"upload": "업로드",
"usageHint": "사용 힌트",
"user": "사용자",
"versionMismatchWarning": "버전 호환성 경고",
"versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로"
},
@@ -393,6 +410,7 @@
"resetView": "보기 재설정",
"selectMode": "선택 모드",
"toggleLinkVisibility": "링크 가시성 전환",
"toggleMinimap": "미니맵 전환",
"zoomIn": "확대",
"zoomOut": "축소"
},
@@ -622,7 +640,7 @@
"enabled": "활성화",
"nodePack": "노드 팩"
},
"inWorkflow": "워크플로 내",
"inWorkflow": "워크플로 내",
"infoPanelEmpty": "정보를 보려면 항목을 클릭하세요",
"installAllMissingNodes": "모든 누락된 노드 설치",
"installSelected": "선택한 항목 설치",
@@ -701,21 +719,27 @@
"batchCountTooltip": "워크플로 작업을 실행 대기열에 반복 추가할 횟수",
"clear": "워크플로 비우기",
"clipspace": "클립스페이스 열기",
"dark": "다크",
"disabled": "비활성화됨",
"disabledTooltip": "워크플로 작업을 자동으로 실행 대기열에 추가하지 않습니다.",
"execute": "실행",
"help": "도움말",
"hideMenu": "메뉴 숨기기",
"instant": "즉시",
"instantTooltip": "워크플로 실행이 완료되면 즉시 실행 대기열에 추가합니다.",
"interrupt": "현재 실행 취소",
"light": "라이트",
"manageExtensions": "확장 프로그램 관리",
"onChange": "변경 시",
"onChangeTooltip": "변경이 있는 경우에만 워크플로를 실행 대기열에 추가합니다.",
"refresh": "노드 정의 새로 고침",
"resetView": "캔버스 보기 재설정",
"run": "실행",
"runWorkflow": "워크플로 실행 (시프트 키와 함께 클릭시 가장 먼저 실행)",
"runWorkflowFront": "워크플로 실행 (가장 먼저 실행)",
"runWorkflow": "워크플로 실행 (시프트 키와 함께 클릭시 가장 먼저 실행)",
"runWorkflowFront": "워크플로 실행 (가장 먼저 실행)",
"settings": "설정",
"showMenu": "메뉴 표시",
"theme": "테마",
"toggleBottomPanel": "하단 패널 전환"
},
"menuLabels": {
@@ -726,6 +750,7 @@
"Canvas Toggle Link Visibility": "캔버스 토글 링크 가시성",
"Canvas Toggle Lock": "캔버스 토글 잠금",
"Check for Custom Node Updates": "커스텀 노드 업데이트 확인",
"Canvas Toggle Minimap": "캔버스 미니맵 전환",
"Check for Updates": "업데이트 확인",
"Clear Pending Tasks": "보류 중인 작업 제거하기",
"Clear Workflow": "워크플로 지우기",
@@ -741,6 +766,7 @@
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes (Legacy)": "커스텀 노드(레거시)",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
"Duplicate Current Workflow": "현재 워크플로 복제",
@@ -753,6 +779,7 @@
"Group Selected Nodes": "선택한 노드 그룹화",
"Help": "도움말",
"Install Missing Custom Nodes": "누락된 커스텀 노드 설치",
"Increase Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 늘리기",
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
@@ -797,11 +824,12 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
@@ -896,7 +924,7 @@
"documentationPage": "문서 페이지",
"inputs": "입력",
"loadError": "도움말을 불러오지 못했습니다: {error}",
"moreHelp": "더 많은 도움말은",
"moreHelp": "더 자세한 도움말은",
"outputs": "출력",
"type": "유형"
},
@@ -1169,7 +1197,6 @@
},
"showFlatList": "평면 목록 표시"
},
"themeToggle": "테마 전환",
"workflowTab": {
"confirmDelete": "정말로 이 워크플로를 삭제하시겠습니까?",
"confirmDeleteTitle": "워크플로 삭제",
@@ -1224,11 +1251,11 @@
"stable_zero123_example": "Stable Zero123"
},
"3D API": {
"api_rodin_image_to_model": "Rodin: 이미지 모델",
"api_rodin_multiview_to_model": "Rodin: 다중뷰 모델",
"api_tripo_image_to_model": "Tripo: 이미지 모델",
"api_tripo_multiview_to_model": "Tripo: 다중뷰 모델",
"api_tripo_text_to_model": "Tripo: 텍스트 모델"
"api_rodin_image_to_model": "Rodin: 이미지 모델",
"api_rodin_multiview_to_model": "Rodin: 다중뷰 모델",
"api_tripo_image_to_model": "Tripo: 이미지 모델",
"api_tripo_multiview_to_model": "Tripo: 다중뷰 모델",
"api_tripo_text_to_model": "Tripo: 텍스트 모델"
},
"Area Composition": {
"area_composition": "영역 구성",
@@ -1236,15 +1263,15 @@
},
"Audio": {
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M 편집",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 텍스트 연주곡",
"audio_ace_step_1_t2a_song": "ACE Step v1 텍스트 노래",
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 텍스트 연주곡",
"audio_ace_step_1_t2a_song": "ACE Step v1 텍스트 노래",
"audio_stable_audio_example": "Stable Audio"
},
"Basics": {
"default": "이미지 생성",
"embedding_example": "임베딩",
"gligen_textbox_example": "글리젠 텍스트박스",
"image2image": "이미지 이미지",
"image2image": "이미지 이미지",
"inpaint_example": "인페인트",
"inpaint_model_outpainting": "아웃페인팅",
"lora": "LoRA",
@@ -1258,62 +1285,62 @@
"mixing_controlnets": "컨트롤넷 혼합"
},
"Flux": {
"flux_canny_model_example": "Flux 캐니 모델",
"flux_depth_lora_example": "Flux 깊이 로라",
"flux_dev_checkpoint_example": "Flux Dev fp8",
"flux_dev_full_text_to_image": "Flux Dev 전체 텍스트 투 이미지",
"flux_fill_inpaint_example": "Flux 인페인트",
"flux_fill_outpaint_example": "Flux 아웃페인트",
"flux_kontext_dev_basic": "Flux Kontext Dev(기본)",
"flux_kontext_dev_grouped": "Flux Kontext Dev(그룹화)",
"flux_redux_model_example": "Flux Redux 모델",
"flux_schnell": "Flux Schnell fp8",
"flux_schnell_full_text_to_image": "Flux Schnell 전체 텍스트 이미지"
"flux_canny_model_example": "FLUX 캐니 모델",
"flux_depth_lora_example": "FLUX 깊이 로라",
"flux_dev_checkpoint_example": "FLUX Dev fp8",
"flux_dev_full_text_to_image": "FLUX Dev 전체 텍스트 투 이미지",
"flux_fill_inpaint_example": "FLUX 인페인트",
"flux_fill_outpaint_example": "FLUX 아웃페인트",
"flux_kontext_dev_basic": "FLUX Kontext Dev(기본)",
"flux_kontext_dev_grouped": "FLUX Kontext Dev(그룹화)",
"flux_redux_model_example": "FLUX Redux 모델",
"flux_schnell": "FLUX Schnell fp8",
"flux_schnell_full_text_to_image": "FLUX Schnell Full 텍스트 이미지"
},
"Image": {
"hidream_e1_full": "HiDream E1 Full",
"hidream_i1_dev": "HiDream I1 Dev",
"hidream_i1_fast": "HiDream I1 Fast",
"hidream_i1_full": "HiDream I1 Full",
"image_chroma_text_to_image": "Chroma 텍스트 이미지",
"image_chroma_text_to_image": "Chroma 텍스트 이미지",
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
"image_lotus_depth_v1_1": "Lotus Depth",
"image_omnigen2_image_edit": "OmniGen2 이미지 편집",
"image_omnigen2_t2i": "OmniGen2 텍스트 이미지",
"sd3_5_large_blur": "SD3.5 대형 블러",
"sd3_5_large_canny_controlnet_example": "SD3.5 대형 캐니 컨트롤넷",
"sd3_5_large_depth": "SD3.5 대형 깊이",
"image_omnigen2_t2i": "OmniGen2 텍스트 이미지",
"sd3_5_large_blur": "SD3.5 Large 블러",
"sd3_5_large_canny_controlnet_example": "SD3.5 Large 캐니 컨트롤넷",
"sd3_5_large_depth": "SD3.5 Large 깊이",
"sd3_5_simple_example": "SD3.5 간단 예제",
"sdxl_refiner_prompt_example": "SDXL 리파이너 프롬프트",
"sdxl_refiner_prompt_example": "SDXL Refiner 프롬프트",
"sdxl_revision_text_prompts": "SDXL Revision 텍스트 프롬프트",
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
"sdxl_simple_example": "SDXL 간단 예제",
"sdxlturbo_example": "SDXL 터보"
},
"Image API": {
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext 맥스",
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext 다중 이미지 입력",
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext 프로",
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]: 텍스트 이미지",
"api_ideogram_v3_t2i": "Ideogram V3: 텍스트 이미지",
"api_luma_photon_i2i": "Luma Photon: 이미지 이미지",
"api_bfl_flux_1_kontext_max_image": "BFL FLUX.1 Kontext 맥스",
"api_bfl_flux_1_kontext_multiple_images_input": "BFL FLUX.1 Kontext 다중 이미지 입력",
"api_bfl_flux_1_kontext_pro_image": "BFL FLUX.1 Kontext 프로",
"api_bfl_flux_pro_t2i": "BFL FLUX[Pro]: 텍스트 이미지",
"api_ideogram_v3_t2i": "Ideogram V3: 텍스트 이미지",
"api_luma_photon_i2i": "Luma Photon: 이미지 이미지",
"api_luma_photon_style_ref": "Luma Photon: 스타일 참조",
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 인페인트",
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 텍스트 이미지",
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 텍스트 이미지",
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 이미지 이미지",
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 텍스트 이미지",
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 텍스트 이미지",
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 이미지 이미지",
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 인페인트",
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 멀티 입력",
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 텍스트 이미지",
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 텍스트 이미지",
"api_recraft_image_gen_with_color_control": "Recraft: 색상 제어 이미지 생성",
"api_recraft_image_gen_with_style_control": "Recraft: 스타일 제어 이미지 생성",
"api_recraft_vector_gen": "Recraft: 벡터 생성",
"api_runway_reference_to_image": "Runway: 참조 이미지",
"api_runway_text_to_image": "Runway: 텍스트 이미지",
"api_stability_ai_i2i": "Stability AI: 이미지 이미지",
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 이미지 이미지",
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 텍스트 이미지",
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra 텍스트 이미지"
"api_runway_reference_to_image": "Runway: 참조 이미지",
"api_runway_text_to_image": "Runway: 텍스트 이미지",
"api_stability_ai_i2i": "Stability AI: 이미지 이미지",
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 이미지 이미지",
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 텍스트 이미지",
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra 텍스트 이미지"
},
"LLM API": {
"api_google_gemini": "Google Gemini: 채팅",
@@ -1321,24 +1348,24 @@
},
"Upscaling": {
"esrgan_example": "ESRGAN",
"hiresfix_esrgan_workflow": "HiresFix ESRGAN 워크플로",
"hiresfix_latent_workflow": "업스케일",
"latent_upscale_different_prompt_model": "Latent 업스케일 다른 프롬프트 모델"
"hiresfix_esrgan_workflow": "HiresFix ESRGAN 워크플로",
"hiresfix_latent_workflow": "이미지 확대",
"latent_upscale_different_prompt_model": "잠재 이미지 확대 다른 프롬프트 모델"
},
"Video": {
"hunyuan_video_text_to_video": "Hunyuan 비디오 텍스트 비디오",
"image_to_video": "SVD 이미지 비디오",
"image_to_video_wan": "Wan 2.1 이미지 비디오",
"ltxv_image_to_video": "LTXV 이미지 비디오",
"ltxv_text_to_video": "LTXV 텍스트 비디오",
"mochi_text_to_video_example": "Mochi 텍스트 비디오",
"text_to_video_wan": "Wan 2.1 텍스트 비디오",
"txt_to_image_to_video": "SVD 텍스트 이미지 비디오",
"hunyuan_video_text_to_video": "Hunyuan 비디오 텍스트 비디오",
"image_to_video": "SVD 이미지 비디오",
"image_to_video_wan": "Wan 2.1 이미지 비디오",
"ltxv_image_to_video": "LTXV 이미지 비디오",
"ltxv_text_to_video": "LTXV 텍스트 비디오",
"mochi_text_to_video_example": "Mochi 텍스트 비디오",
"text_to_video_wan": "Wan 2.1 텍스트 비디오",
"txt_to_image_to_video": "SVD 텍스트 이미지 비디오",
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps",
"video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B",
"video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B",
"video_wan_vace_14B_ref2v": "Wan VACE 참조 비디오",
"video_wan_vace_14B_t2v": "Wan VACE 텍스트 비디오",
"video_wan_vace_14B_ref2v": "Wan VACE 참조 비디오",
"video_wan_vace_14B_t2v": "Wan VACE 텍스트 비디오",
"video_wan_vace_14B_v2v": "Wan VACE 컨트롤 비디오",
"video_wan_vace_flf2v": "Wan VACE 첫-마지막 프레임",
"video_wan_vace_inpainting": "Wan VACE 인페인팅",
@@ -1348,24 +1375,24 @@
"wan2_1_fun_inp": "Wan 2.1 인페인팅"
},
"Video API": {
"api_hailuo_minimax_i2v": "MiniMax: 이미지 비디오",
"api_hailuo_minimax_t2v": "MiniMax: 텍스트 비디오",
"api_hailuo_minimax_i2v": "MiniMax: 이미지 비디오",
"api_hailuo_minimax_t2v": "MiniMax: 텍스트 비디오",
"api_kling_effects": "Kling: 비디오 효과",
"api_kling_flf": "Kling: FLF2V",
"api_kling_i2v": "Kling: 이미지 비디오",
"api_luma_i2v": "Luma: 이미지 비디오",
"api_luma_t2v": "Luma: 텍스트 비디오",
"api_moonvalley_image_to_video": "Moonvalley: 이미지 비디오",
"api_moonvalley_text_to_video": "Moonvalley: 텍스트 비디오",
"api_pika_i2v": "Pika: 이미지 비디오",
"api_pika_scene": "Pika 장면: 이미지 비디오",
"api_pixverse_i2v": "PixVerse: 이미지 비디오",
"api_pixverse_t2v": "PixVerse: 텍스트 비디오",
"api_pixverse_template_i2v": "PixVerse 템플릿: 이미지 비디오",
"api_runway_first_last_frame": "Runway: 첫-마지막 프레임 비디오",
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo 이미지 비디오",
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo 이미지 비디오",
"api_veo2_i2v": "Veo2: 이미지 비디오"
"api_kling_i2v": "Kling: 이미지 비디오",
"api_luma_i2v": "Luma: 이미지 비디오",
"api_luma_t2v": "Luma: 텍스트 비디오",
"api_moonvalley_image_to_video": "Moonvalley: 이미지 비디오",
"api_moonvalley_text_to_video": "Moonvalley: 텍스트 비디오",
"api_pika_i2v": "Pika: 이미지 비디오",
"api_pika_scene": "Pika 장면: 이미지 비디오",
"api_pixverse_i2v": "PixVerse: 이미지 비디오",
"api_pixverse_t2v": "PixVerse: 텍스트 비디오",
"api_pixverse_template_i2v": "PixVerse 템플릿: 이미지 비디오",
"api_runway_first_last_frame": "Runway: 첫-마지막 프레임 비디오",
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo 이미지 비디오",
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo 이미지 비디오",
"api_veo2_i2v": "Veo2: 이미지 비디오"
}
},
"templateDescription": {
@@ -1380,7 +1407,7 @@
"api_rodin_multiview_to_model": "Rodin의 다각도 재구성으로 종합적인 3D 모델을 만듭니다.",
"api_tripo_image_to_model": "Tripo 엔진으로 2D 이미지에서 전문가용 3D 에셋을 생성합니다.",
"api_tripo_multiview_to_model": "Tripo의 고급 스캐너로 여러 각도에서 3D 모델을 만듭니다.",
"api_tripo_text_to_model": "Tripo의 텍스트 기반 모델링으로 설명에서 3D 오브젝트를 만듭니다."
"api_tripo_text_to_model": "Tripo의 텍스트 기반 모델링으로 설명에서 3D 객체를 만듭니다."
},
"Area Composition": {
"area_composition": "정의된 영역으로 구성을 제어하여 이미지를 생성합니다.",
@@ -1403,49 +1430,49 @@
"lora_multiple": "여러 LoRA 모델을 결합하여 이미지를 생성합니다."
},
"ControlNet": {
"2_pass_pose_worship": "ControlNet으로 포즈 참조를 활용해 이미지를 생성합니다.",
"controlnet_example": "ControlNet으로 스크리블 참조 이미지를 활용해 이미지를 생성합니다.",
"depth_controlnet": "ControlNet으로 깊이 정보를 활용해 이미지를 생성합니다.",
"2_pass_pose_worship": "컨트롤넷으로 포즈 참조를 활용해 이미지를 생성합니다.",
"controlnet_example": "컨트롤넷으로 스크리블 참조 이미지를 활용해 이미지를 생성합니다.",
"depth_controlnet": "컨트롤넷으로 깊이 정보를 활용해 이미지를 생성합니다.",
"depth_t2i_adapter": "T2I 어댑터로 깊이 정보를 활용해 이미지를 생성합니다.",
"mixing_controlnets": "여러 ControlNet 모델을 결합해 이미지를 생성합니다."
"mixing_controlnets": "여러 컨트롤넷 모델을 결합해 이미지를 생성합니다."
},
"Flux": {
"flux_canny_model_example": "Flux Canny로 에지 감지에 따라 이미지를 생성합니다.",
"flux_depth_lora_example": "Flux LoRA로 깊이 정보를 활용해 이미지를 생성합니다.",
"flux_dev_checkpoint_example": "Flux Dev fp8 양자화 버전으로 이미지를 생성합니다. VRAM이 제한된 장치에 적합하며, 모델 파일 하나만 필요하지만 화질은 전체 버전보다 약간 낮습니다.",
"flux_dev_full_text_to_image": "Flux Dev 전체 버전으로 고품질 이미지를 생성합니다. 더 많은 VRAM과 여러 모델 파일이 필요하지만, 최고의 프롬프트 반영력과 화질을 제공합니다.",
"flux_fill_inpaint_example": "Flux 인페인팅으로 이미지의 누락된 부분을 채웁니다.",
"flux_fill_outpaint_example": "Flux 아웃페인팅으로 이미지를 경계 너머로 확장합니다.",
"flux_kontext_dev_basic": "Flux Kontext의 전체 노드 표시로 이미지를 편집합니다. 워크플로 학습에 적합합니다.",
"flux_kontext_dev_grouped": "노드가 그룹화된 Flux Kontext의 간소화 버전으로 작업 공간이 더 깔끔합니다.",
"flux_redux_model_example": "Flux Redux로 참조 이미지의 스타일을 전송하여 이미지를 생성합니다.",
"flux_schnell": "Flux Schnell fp8 양자화 버전으로 이미지를 빠르게 생성합니다. 저사양 하드웨어에 이상적이며, 4단계만으로 이미지를 생성할 수 있습니다.",
"flux_schnell_full_text_to_image": "Flux Schnell 전체 버전으로 이미지를 빠르게 생성합니다. Apache2.0 라이선스를 사용하며, 4단계만으로 좋은 화질을 유지합니다."
"flux_canny_model_example": "FLUX 캐니 컨트롤넷으로 에지 감지에 따라 이미지를 생성합니다.",
"flux_depth_lora_example": "FLUX LoRA로 깊이 정보를 활용해 이미지를 생성합니다.",
"flux_dev_checkpoint_example": "FLUX Dev fp8 양자화 버전으로 이미지를 생성합니다. VRAM이 제한된 장치에 적합하며, 모델 파일 하나만 필요하지만 화질은 전체 버전보다 약간 낮습니다.",
"flux_dev_full_text_to_image": "FLUX Dev 전체 버전으로 고품질 이미지를 생성합니다. 더 많은 VRAM과 여러 모델 파일이 필요하지만, 최고의 프롬프트 반영력과 화질을 제공합니다.",
"flux_fill_inpaint_example": "FLUX 인페인팅으로 이미지의 누락된 부분을 채웁니다.",
"flux_fill_outpaint_example": "FFLUXlux 아웃페인팅으로 이미지를 경계 너머로 확장합니다.",
"flux_kontext_dev_basic": "FLUX Kontext의 전체 노드 표시로 이미지를 편집합니다. 워크플로 학습에 적합합니다.",
"flux_kontext_dev_grouped": "노드가 그룹화된 FLUX Kontext의 간소화 버전으로 작업 공간이 더 깔끔합니다.",
"flux_redux_model_example": "FLUX Redux로 참조 이미지의 스타일을 전송하여 이미지를 생성합니다.",
"flux_schnell": "FLUX Schnell fp8 양자화 버전으로 이미지를 빠르게 생성합니다. 저사양 하드웨어에 이상적이며, 4단계만으로 이미지를 생성할 수 있습니다.",
"flux_schnell_full_text_to_image": "FLUX Schnell Full 버전을 이용해 이미지를 빠르게 생성합니다. Apache2.0 라이선스를 사용하며, 4단계만으로 좋은 화질을 유지합니다."
},
"Image": {
"hidream_e1_full": "HiDream E1 - 전문적인 자연어 이미지 편집 모델로 이미지를 편집합니다.",
"hidream_i1_dev": "HiDream I1 Dev - 28 스텝의 균형 잡힌 버전으로, 중간급 하드웨어에 적합합니다.",
"hidream_i1_fast": "HiDream I1 Fast - 16 스텝의 경량 버전으로, 저사양 하드웨어에서 빠른 미리보기에 적합합니다.",
"hidream_i1_full": "HiDream I1 Full - 50 스텝의 완전 버전으로, 최고의 품질을 제공합니다.",
"image_chroma_text_to_image": "Chroma는 flux에서 수정된 모델로, 아키텍처에 일부 변화가 있습니다.",
"image_chroma_text_to_image": "Chroma는 FLUX에서 수정된 모델로, 아키텍처에 일부 변화가 있습니다.",
"image_cosmos_predict2_2B_t2i": "Cosmos-Predict2 2B T2I로 물리적으로 정확하고 고해상도, 디테일이 풍부한 이미지를 생성합니다.",
"image_lotus_depth_v1_1": "Lotus Depth로 고효율 단안 깊이 추정 및 디테일 보존이 뛰어난 zero-shot 이미지를 생성합니다.",
"image_omnigen2_image_edit": "OmniGen2의 고급 이미지 편집 기능과 텍스트 렌더링 지원으로 자연어 지시로 이미지를 편집합니다.",
"image_omnigen2_t2i": "OmniGen2의 통합 7B 멀티모달 모델과 듀얼 패스 아키텍처로 텍스트 프롬프트에서 고품질 이미지를 생성합니다.",
"sd3_5_large_blur": "SD 3.5로 흐릿한 참조 이미지를 활용해 이미지를 생성합니다.",
"sd3_5_large_canny_controlnet_example": "SD 3.5 Canny ControlNet으로 에지 감지에 따라 이미지를 생성합니다.",
"sd3_5_large_canny_controlnet_example": "SD 3.5 캐니 컨트롤넷으로 에지 감지에 따라 이미지를 생성합니다.",
"sd3_5_large_depth": "SD 3.5로 깊이 정보를 활용해 이미지를 생성합니다.",
"sd3_5_simple_example": "SD 3.5로 이미지를 생성합니다.",
"sdxl_refiner_prompt_example": "SDXL 리파이너 모델로 이미지를 향상시킵니다.",
"sdxl_refiner_prompt_example": "SDXL Refiner 모델로 이미지를 향상시킵니다.",
"sdxl_revision_text_prompts": "SDXL Revision으로 참조 이미지의 개념을 전송하여 이미지를 생성합니다.",
"sdxl_revision_zero_positive": "SDXL Revision으로 텍스트 프롬프트와 참조 이미지를 함께 사용해 이미지를 생성합니다.",
"sdxl_simple_example": "SDXL로 고품질 이미지를 생성합니다.",
"sdxlturbo_example": "SDXL Turbo로 한 번에 이미지를 생성합니다."
},
"Image API": {
"api_bfl_flux_1_kontext_max_image": "Flux.1 Kontext 맥스 이미지로 이미지를 편집합니다.",
"api_bfl_flux_1_kontext_multiple_images_input": "여러 이미지를 입력하고 Flux.1 Kontext로 편집합니다.",
"api_bfl_flux_1_kontext_pro_image": "Flux.1 Kontext 프로 이미지로 이미지를 편집합니다.",
"api_bfl_flux_1_kontext_max_image": "FLUX.1 Kontext 맥스 이미지로 이미지를 편집합니다.",
"api_bfl_flux_1_kontext_multiple_images_input": "여러 이미지를 입력하고 FLUX.1 Kontext로 편집합니다.",
"api_bfl_flux_1_kontext_pro_image": "FLUX.1 Kontext 프로 이미지로 이미지를 편집합니다.",
"api_bfl_flux_pro_t2i": "FLUX.1 Pro로 뛰어난 프롬프트 반영과 시각적 품질로 이미지를 생성합니다.",
"api_ideogram_v3_t2i": "Ideogram V3로 뛰어난 프롬프트 일치, 포토리얼리즘, 텍스트 렌더링으로 전문가 수준의 이미지를 생성합니다.",
"api_luma_photon_i2i": "이미지와 프롬프트를 조합하여 이미지 생성을 가이드합니다.",
@@ -1463,9 +1490,9 @@
"api_runway_reference_to_image": "Runway의 AI로 참조 스타일과 구성을 기반으로 새 이미지를 생성합니다.",
"api_runway_text_to_image": "Runway의 AI 모델로 텍스트 프롬프트에서 고품질 이미지를 생성합니다.",
"api_stability_ai_i2i": "Stability AI로 고품질 이미지 변환 및 스타일 전환을 지원합니다.",
"api_stability_ai_sd3_5_i2i": "1메가픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다.",
"api_stability_ai_sd3_5_t2i": "1메가픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다.",
"api_stability_ai_stable_image_ultra_t2i": "1메가픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다."
"api_stability_ai_sd3_5_i2i": "1M 픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다.",
"api_stability_ai_sd3_5_t2i": "1M 픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다.",
"api_stability_ai_stable_image_ultra_t2i": "1M 픽셀 해상도에서 전문가용 고품질 이미지를 생성합니다. 프롬프트 반영이 우수합니다."
},
"LLM API": {
"api_google_gemini": "Google Gemini의 멀티모달 AI와 추론 능력을 경험하세요.",
@@ -1473,9 +1500,9 @@
},
"Upscaling": {
"esrgan_example": "ESRGAN 모델로 이미지 품질을 향상합니다.",
"hiresfix_esrgan_workflow": "중간 생성 단계에서 ESRGAN 모델로 업스케일합니다.",
"hiresfix_latent_workflow": "Latent 공간에서 이미지 품질을 향상합니다.",
"latent_upscale_different_prompt_model": "여러 번의 생성 패스에서 프롬프트를 변경하며 업스케일합니다."
"hiresfix_esrgan_workflow": "중간 생성 단계에서 ESRGAN 모델로 이미지를 확대합니다.",
"hiresfix_latent_workflow": "잠재 이미지의 확대 방식으로 이미지 품질을 향상합니다.",
"latent_upscale_different_prompt_model": "여러 번의 생성 패스에서 프롬프트를 변경하며 이미지를 확대합니다."
},
"Video": {
"hunyuan_video_text_to_video": "Hunyuan 모델로 텍스트 프롬프트에서 비디오를 생성합니다.",
@@ -1496,7 +1523,7 @@
"video_wan_vace_inpainting": "특정 영역을 편집하면서 주변 내용을 보존하는 비디오를 생성합니다. 객체 제거 또는 교체에 적합합니다.",
"video_wan_vace_outpainting": "Wan VACE 아웃페인팅으로 비디오 크기를 확장하여 비디오를 생성합니다.",
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V로 첫 프레임과 마지막 프레임을 제어하여 비디오를 생성합니다.",
"wan2_1_fun_control": "Wan 2.1 ControlNet으로 포즈, 깊이, 에지 제어로 비디오를 생성합니다.",
"wan2_1_fun_control": "Wan 2.1 컨트롤넷으로 포즈, 깊이, 에지 제어로 적용해 비디오를 생성합니다.",
"wan2_1_fun_inp": "Wan 2.1 인페인팅으로 시작 및 종료 프레임에서 비디오를 생성합니다."
},
"Video API": {
@@ -1540,7 +1567,7 @@
"failedToFetchLogs": "서버 로그를 가져오는 데 실패했습니다",
"failedToInitiateCreditPurchase": "크레딧 구매를 시작하지 못했습니다: {error}",
"failedToPurchaseCredits": "크레딧 구매에 실패했습니다: {error}",
"fileLoadError": "{fileName}에서 워크플로를 찾을 수 없습니다",
"fileLoadError": "{fileName}에서 워크플로를 찾을 수 없습니다",
"fileUploadFailed": "파일 업로드에 실패했습니다",
"interrupted": "실행이 중단되었습니다",
"migrateToLitegraphReroute": "향후 버전에서는 Reroute 노드가 제거됩니다. LiteGraph 에서 자체 제공하는 경유점으로 변환하려면 클릭하세요.",
@@ -1593,6 +1620,13 @@
"prefix": "{prefix}(으)로 시작해야 합니다",
"required": "필수"
},
"versionMismatchWarning": {
"dismiss": "닫기",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상 버전을 필요로 합니다.",
"title": "버전 호환성 경고",
"updateFrontend": "프론트엔드 업데이트"
},
"welcome": {
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"

View File

@@ -29,6 +29,13 @@
"name": "캔버스 배경 이미지",
"tooltip": "캔버스 배경에 사용할 이미지 URL입니다. 출력 패널에서 이미지를 마우스 오른쪽 버튼으로 클릭한 후 \"배경으로 설정\"을 선택해 사용할 수 있습니다."
},
"Comfy_Canvas_NavigationMode": {
"name": "캔버스 내비게이션 모드",
"options": {
"Left-Click Pan (Legacy)": "왼쪽 클릭 이동(레거시)",
"Standard (New)": "표준(신규)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "선택 도구 상자 표시"
},
@@ -329,10 +336,6 @@
},
"tooltip": "메뉴 바 위치입니다. 모바일 기기에서는 메뉴가 항상 상단에 표시됩니다."
},
"Comfy_Validation_NodeDefs": {
"name": "노드 정의 유효성 검사 (느림)",
"tooltip": "노드 개발자에게 권장됩니다. 시작 시 모든 노드 정의를 유효성 검사합니다."
},
"Comfy_Validation_Workflows": {
"name": "워크플로 유효성 검사"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "툴팁 지연"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "트랙패드 제스처 활성화",
"tooltip": "이 설정을 켜면 캔버스에서 트랙패드 모드를 사용할 수 있으며, 두 손가락으로 확대/축소 및 이동이 가능합니다."
},
"LiteGraph_Reroute_SplineOffset": {
"name": "경유점 스플라인 오프셋",
"tooltip": "경유점 중심에서 베지어 제어점까지의 오프셋"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "Переключить блокировку холста"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "Полотно: переключить миникарту"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "Обход/Необход выбранных нод"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "Уменьшить размер кисти в MaskEditor"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Увеличить размер кисти в MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Открыть редактор масок для выбранной ноды"
},

View File

@@ -82,6 +82,12 @@
"title": "Создать аккаунт"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "Очистить рабочий процесс",
"deleteWorkflow": "Удалить рабочий процесс",
"duplicate": "Дублировать",
"enterNewName": "Введите новое имя"
},
"chatHistory": {
"cancelEdit": "Отмена",
"cancelEditTooltip": "Отменить редактирование",
@@ -291,6 +297,7 @@
"devices": "Устройства",
"disableAll": "Отключить все",
"disabling": "Отключение",
"dismiss": "Закрыть",
"download": "Скачать",
"edit": "Редактировать",
"empty": "Пусто",
@@ -305,6 +312,8 @@
"filter": "Фильтр",
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"frontendNewer": "Версия интерфейса {frontendVersion} может быть несовместима с версией сервера {backendVersion}.",
"frontendOutdated": "Версия интерфейса {frontendVersion} устарела. Требуется версия не ниже {requiredVersion} для работы с сервером.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
@@ -326,17 +335,20 @@
"loadingPanel": "Загрузка панели {panel}...",
"login": "Вход",
"logs": "Логи",
"micPermissionDenied": "Доступ к микрофону запрещён",
"migrate": "Мигрировать",
"missing": "Отсутствует",
"name": "Имя",
"newFolder": "Новая папка",
"next": "Далее",
"no": "Нет",
"noAudioRecorded": "Аудио не записано",
"noResultsFound": "Результатов не найдено",
"noTasksFound": "Задачи не найдены",
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"nodesRunning": "запущено узлов",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",
@@ -370,7 +382,9 @@
"showReport": "Показать отчёт",
"sort": "Сортировать",
"source": "Источник",
"startRecording": "Начать запись",
"status": "Статус",
"stopRecording": "Остановить запись",
"success": "Успех",
"systemInfo": "Информация о системе",
"terminal": "Терминал",
@@ -379,11 +393,14 @@
"unknownError": "Неизвестная ошибка",
"update": "Обновить",
"updateAvailable": "Доступно обновление",
"updateFrontend": "Обновить интерфейс",
"updated": "Обновлено",
"updating": "Обновление",
"upload": "Загрузить",
"usageHint": "Подсказка по использованию",
"user": "Пользователь",
"versionMismatchWarning": "Предупреждение о несовместимости версий",
"versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.",
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
},
@@ -393,6 +410,7 @@
"resetView": "Сбросить вид",
"selectMode": "Выбрать режим",
"toggleLinkVisibility": "Переключить видимость ссылок",
"toggleMinimap": "Показать/скрыть миникарту",
"zoomIn": "Увеличить",
"zoomOut": "Уменьшить"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "Количество раз, когда генерация рабочего процесса должна быть помещена в очередь",
"clear": "Очистить рабочий процесс",
"clipspace": "Открыть Clipspace",
"dark": "Тёмная",
"disabled": "Отключено",
"disabledTooltip": "Рабочий процесс не будет автоматически помещён в очередь",
"execute": "Выполнить",
"help": "Справка",
"hideMenu": "Скрыть меню",
"instant": "Мгновенно",
"instantTooltip": "Рабочий процесс будет помещён в очередь сразу же после завершения генерации",
"interrupt": "Отменить текущее выполнение",
"light": "Светлая",
"manageExtensions": "Управление расширениями",
"onChange": "При изменении",
"onChangeTooltip": "Рабочий процесс будет поставлен в очередь после внесения изменений",
"refresh": "Обновить определения нод",
@@ -715,7 +737,9 @@
"run": "Запустить",
"runWorkflow": "Запустить рабочий процесс (Shift для очереди в начале)",
"runWorkflowFront": "Запустить рабочий процесс (Очередь в начале)",
"settings": "Настройки",
"showMenu": "Показать меню",
"theme": "Тема",
"toggleBottomPanel": "Переключить нижнюю панель"
},
"menuLabels": {
@@ -727,6 +751,8 @@
"Canvas Toggle Lock": "Переключение блокировки холста",
"Check for Custom Node Updates": "Проверить обновления пользовательских узлов",
"Check for Updates": "Проверить Обновления",
"Canvas Toggle Minimap": "Показать/скрыть миникарту на холсте",
"Check for Updates": "Проверить наличие обновлений",
"Clear Pending Tasks": "Очистить ожидающие задачи",
"Clear Workflow": "Очистить рабочий процесс",
"Clipspace": "Клиппространство",
@@ -741,6 +767,7 @@
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes (Legacy)": "Пользовательские узлы (устаревшие)",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Decrease Brush Size in MaskEditor": "Уменьшить размер кисти в MaskEditor",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
@@ -753,6 +780,7 @@
"Group Selected Nodes": "Сгруппировать выбранные ноды",
"Help": "Помощь",
"Install Missing Custom Nodes": "Установить отсутствующие пользовательские узлы",
"Increase Brush Size in MaskEditor": "Увеличить размер кисти в MaskEditor",
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
@@ -1169,7 +1197,6 @@
},
"showFlatList": "Показать плоский список"
},
"themeToggle": "Переключить тему",
"workflowTab": {
"confirmDelete": "Вы уверены, что хотите удалить этот рабочий процесс?",
"confirmDeleteTitle": "Удалить рабочий процесс?",
@@ -1593,6 +1620,13 @@
"prefix": "Должно начинаться с {prefix}",
"required": "Обязательно"
},
"versionMismatchWarning": {
"dismiss": "Закрыть",
"frontendNewer": "Версия интерфейса {frontendVersion} может быть несовместима с версией сервера {backendVersion}.",
"frontendOutdated": "Версия интерфейса {frontendVersion} устарела. Для работы с сервером требуется версия {requiredVersion} или новее.",
"title": "Предупреждение о несовместимости версий",
"updateFrontend": "Обновить интерфейс"
},
"welcome": {
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"

View File

@@ -29,6 +29,13 @@
"name": "Фоновое изображение холста",
"tooltip": "URL изображения для фона холста. Вы можете кликнуть правой кнопкой мыши на изображении в панели результатов и выбрать «Установить как фон», чтобы использовать его."
},
"Comfy_Canvas_NavigationMode": {
"name": "Режим навигации по холсту",
"options": {
"Left-Click Pan (Legacy)": "Перемещение левой кнопкой (устаревший)",
"Standard (New)": "Стандартный (новый)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Показать панель инструментов выбора"
},
@@ -329,10 +336,6 @@
},
"tooltip": "Расположение панели меню. На мобильных устройствах меню всегда отображается вверху."
},
"Comfy_Validation_NodeDefs": {
"name": "Проверка определений нод (медленно)",
"tooltip": "Рекомендуется для разработчиков нод. Это проверит все определения нод при запуске."
},
"Comfy_Validation_Workflows": {
"name": "Проверка рабочих процессов"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "Задержка всплывающей подсказки"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "Включить жесты трекпада",
"tooltip": "Эта настройка включает режим трекпада для холста, позволяя использовать масштабирование щипком и панорамирование двумя пальцами."
},
"LiteGraph_Reroute_SplineOffset": {
"name": "Перераспределение смещения сплайна",
"tooltip": "Смещение контрольной точки Безье от центральной точки перераспределения"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "畫布切換鎖定"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "畫布切換小地圖"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "略過/取消略過選取的節點"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切換自訂節點管理器進度條"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "減少 MaskEditor 畫筆大小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "增加 MaskEditor 畫筆大小"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "為選取的節點開啟 Mask 編輯器"
},

View File

@@ -82,6 +82,12 @@
"title": "建立帳戶"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清除工作流程",
"deleteWorkflow": "刪除工作流程",
"duplicate": "複製",
"enterNewName": "輸入新名稱"
},
"chatHistory": {
"cancelEdit": "取消",
"cancelEditTooltip": "取消編輯",
@@ -291,6 +297,7 @@
"devices": "裝置",
"disableAll": "全部停用",
"disabling": "停用中",
"dismiss": "關閉",
"download": "下載",
"edit": "編輯",
"empty": "空",
@@ -305,6 +312,8 @@
"filter": "篩選",
"findIssues": "尋找問題",
"firstTimeUIMessage": "這是您第一次使用新介面。若要返回舊介面,請前往「選單」>「使用新介面」>「關閉」。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "前往節點",
"help": "說明",
"icon": "圖示",
@@ -326,17 +335,20 @@
"loadingPanel": "正在載入{panel}面板...",
"login": "登入",
"logs": "日誌",
"micPermissionDenied": "麥克風權限被拒絕",
"migrate": "遷移",
"missing": "缺少",
"name": "名稱",
"newFolder": "新資料夾",
"next": "下一步",
"no": "否",
"noAudioRecorded": "沒有錄製到音訊",
"noResultsFound": "找不到結果",
"noTasksFound": "找不到任務",
"noTasksFoundMessage": "佇列中沒有任務。",
"noWorkflowsFound": "找不到工作流程。",
"nodes": "節點",
"nodesRunning": "節點執行中",
"ok": "確定",
"openNewIssue": "開啟新問題",
"overwrite": "覆蓋",
@@ -370,7 +382,9 @@
"showReport": "顯示報告",
"sort": "排序",
"source": "來源",
"startRecording": "開始錄音",
"status": "狀態",
"stopRecording": "停止錄音",
"success": "成功",
"systemInfo": "系統資訊",
"terminal": "終端機",
@@ -379,11 +393,14 @@
"unknownError": "未知錯誤",
"update": "更新",
"updateAvailable": "有可用更新",
"updateFrontend": "更新前端",
"updated": "已更新",
"updating": "更新中",
"upload": "上傳",
"usageHint": "使用提示",
"user": "使用者",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "無法載入影片",
"workflow": "工作流程"
},
@@ -393,6 +410,7 @@
"resetView": "重設視圖",
"selectMode": "選取模式",
"toggleLinkVisibility": "切換連結顯示",
"toggleMinimap": "切換小地圖",
"zoomIn": "放大",
"zoomOut": "縮小"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "工作流程產生應排入佇列的次數",
"clear": "清除工作流程",
"clipspace": "開啟 Clipspace",
"dark": "深色",
"disabled": "已停用",
"disabledTooltip": "工作流程將不會自動排入佇列",
"execute": "執行",
"help": "說明",
"hideMenu": "隱藏選單",
"instant": "立即",
"instantTooltip": "每次產生完成後,工作流程會立即排入佇列",
"interrupt": "取消目前執行",
"light": "淺色",
"manageExtensions": "管理擴充功能",
"onChange": "變更時",
"onChangeTooltip": "每當有變更時,工作流程會排入佇列",
"refresh": "重新整理節點定義",
@@ -715,7 +737,9 @@
"run": "執行",
"runWorkflow": "執行工作流程Shift 於前方排隊)",
"runWorkflowFront": "執行工作流程(前方排隊)",
"settings": "設定",
"showMenu": "顯示選單",
"theme": "主題",
"toggleBottomPanel": "切換下方面板"
},
"menuLabels": {
@@ -726,6 +750,7 @@
"Canvas Toggle Link Visibility": "切換連結可見性",
"Canvas Toggle Lock": "切換畫布鎖定",
"Check for Custom Node Updates": "檢查自訂節點更新",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Updates": "檢查更新",
"Clear Pending Tasks": "清除待處理任務",
"Clear Workflow": "清除工作流程",
@@ -741,6 +766,7 @@
"Convert selected nodes to group node": "將選取節點轉為群組節點",
"Custom Nodes (Legacy)": "自訂節點(舊版)",
"Custom Nodes Manager": "自訂節點管理員",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
"Delete Selected Items": "刪除選取項目",
"Desktop User Guide": "桌面應用程式使用指南",
"Duplicate Current Workflow": "複製目前工作流程",
@@ -753,6 +779,7 @@
"Group Selected Nodes": "群組選取節點",
"Help": "說明",
"Install Missing Custom Nodes": "安裝缺少的自訂節點",
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小",
"Interrupt": "中斷",
"Load Default Workflow": "載入預設工作流程",
"Manage group nodes": "管理群組節點",
@@ -1169,7 +1196,6 @@
},
"showFlatList": "顯示平面清單"
},
"themeToggle": "切換主題",
"workflowTab": {
"confirmDelete": "您確定要刪除這個工作流程嗎?",
"confirmDeleteTitle": "刪除工作流程?",
@@ -1593,6 +1619,13 @@
"prefix": "必須以 {prefix} 開頭",
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要版本 {requiredVersion} 或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
"welcome": {
"getStarted": "開始使用",
"title": "歡迎使用 ComfyUI"

View File

@@ -29,6 +29,13 @@
"name": "畫布背景圖片",
"tooltip": "畫布背景的圖片網址。你可以在輸出面板中右鍵點擊圖片並選擇「設為背景」來使用,或是使用上傳按鈕上傳你自己的圖片。"
},
"Comfy_Canvas_NavigationMode": {
"name": "畫布導航模式",
"options": {
"Left-Click Pan (Legacy)": "左鍵拖曳平移(舊版)",
"Standard (New)": "標準(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "顯示選取工具箱"
},
@@ -329,10 +336,6 @@
},
"tooltip": "選單列位置。在行動裝置上,選單永遠顯示在頂部。"
},
"Comfy_Validation_NodeDefs": {
"name": "驗證節點定義(較慢)",
"tooltip": "建議節點開發者使用。這會在啟動時驗證所有節點定義。"
},
"Comfy_Validation_Workflows": {
"name": "驗證工作流程"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "提示延遲"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "啟用觸控板手勢",
"tooltip": "此設定可為畫布啟用觸控板模式,允許使用兩指縮放與平移。"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "重導樣條偏移",
"tooltip": "貝茲控制點相對於重導中心點的偏移量"

View File

@@ -71,6 +71,9 @@
"Comfy_Canvas_ToggleLock": {
"label": "锁定视图"
},
"Comfy_Canvas_ToggleMinimap": {
"label": "畫布切換小地圖"
},
"Comfy_Canvas_ToggleSelectedNodes_Bypass": {
"label": "忽略/取消忽略选中节点"
},
@@ -170,6 +173,12 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
},
"Comfy_MaskEditor_BrushSize_Decrease": {
"label": "減小 MaskEditor 中的筆刷大小"
},
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "增加 MaskEditor 畫筆大小"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"
},

View File

@@ -82,6 +82,12 @@
"title": "创建一个账户"
}
},
"breadcrumbsMenu": {
"clearWorkflow": "清除工作流程",
"deleteWorkflow": "刪除工作流程",
"duplicate": "複製",
"enterNewName": "輸入新名稱"
},
"chatHistory": {
"cancelEdit": "取消",
"cancelEditTooltip": "取消编辑",
@@ -291,6 +297,7 @@
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"dismiss": "關閉",
"download": "下载",
"edit": "编辑",
"empty": "空",
@@ -305,6 +312,8 @@
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
@@ -326,17 +335,20 @@
"loadingPanel": "正在加载{panel}面板...",
"login": "登录",
"logs": "日志",
"micPermissionDenied": "麦克风权限被拒绝",
"migrate": "迁移",
"missing": "缺失",
"name": "名称",
"newFolder": "新文件夹",
"next": "下一个",
"no": "否",
"noAudioRecorded": "未录制音频",
"noResultsFound": "未找到结果",
"noTasksFound": "未找到任务",
"noTasksFoundMessage": "队列中没有任务。",
"noWorkflowsFound": "未找到工作流。",
"nodes": "节点",
"nodesRunning": "节点正在运行",
"ok": "确定",
"openNewIssue": "打开新问题",
"overwrite": "覆盖",
@@ -370,7 +382,9 @@
"showReport": "显示报告",
"sort": "排序",
"source": "来源",
"startRecording": "开始录音",
"status": "状态",
"stopRecording": "停止录音",
"success": "成功",
"systemInfo": "系统信息",
"terminal": "终端",
@@ -379,11 +393,14 @@
"unknownError": "未知错误",
"update": "更新",
"updateAvailable": "有更新可用",
"updateFrontend": "更新前端",
"updated": "已更新",
"updating": "更新中",
"upload": "上传",
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
@@ -393,6 +410,7 @@
"resetView": "重置视图",
"selectMode": "选择模式",
"toggleLinkVisibility": "切换连线可见性",
"toggleMinimap": "切換小地圖",
"zoomIn": "放大",
"zoomOut": "缩小"
},
@@ -701,13 +719,17 @@
"batchCountTooltip": "工作流生成次数",
"clear": "清空工作流",
"clipspace": "打开剪贴板",
"dark": "深色",
"disabled": "禁用",
"disabledTooltip": "工作流将不会自动执行",
"execute": "执行",
"help": "說明",
"hideMenu": "隐藏菜单",
"instant": "实时",
"instantTooltip": "工作流将会在生成完成后立即执行",
"interrupt": "取消当前任务",
"light": "淺色",
"manageExtensions": "管理擴充功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"refresh": "刷新节点",
@@ -715,7 +737,9 @@
"run": "运行",
"runWorkflow": "运行工作流程Shift排在前面",
"runWorkflowFront": "运行工作流程(排在前面)",
"settings": "設定",
"showMenu": "显示菜单",
"theme": "主題",
"toggleBottomPanel": "底部面板"
},
"menuLabels": {
@@ -726,6 +750,7 @@
"Canvas Toggle Link Visibility": "切换连线可见性",
"Canvas Toggle Lock": "切换视图锁定",
"Check for Custom Node Updates": "檢查自訂節點更新",
"Canvas Toggle Minimap": "畫布切換小地圖",
"Check for Updates": "检查更新",
"Clear Pending Tasks": "清除待处理任务",
"Clear Workflow": "清除工作流",
@@ -741,6 +766,7 @@
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes (Legacy)": "自訂節點(舊版)",
"Custom Nodes Manager": "自定义节点管理器",
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
"Duplicate Current Workflow": "复制当前工作流",
@@ -753,6 +779,7 @@
"Group Selected Nodes": "将选中节点转换为组节点",
"Help": "帮助",
"Install Missing Custom Nodes": "安裝缺少的自訂節點",
"Increase Brush Size in MaskEditor": "在 MaskEditor 中增大筆刷大小",
"Interrupt": "中断",
"Load Default Workflow": "加载默认工作流",
"Manage group nodes": "管理组节点",
@@ -795,13 +822,15 @@
"Toggle Bottom Panel": "切换底部面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
"Ungroup selected group nodes": "解散选中组节点",
@@ -1169,7 +1198,6 @@
},
"showFlatList": "平铺结果"
},
"themeToggle": "主题切换",
"workflowTab": {
"confirmDelete": "您确定要删除此工作流吗?",
"confirmDeleteTitle": "删除工作流?",
@@ -1593,6 +1621,13 @@
"prefix": "必须以 {prefix} 开头",
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
"welcome": {
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"

View File

@@ -29,6 +29,13 @@
"name": "画布背景图像",
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片并选择“设为背景”来使用它。"
},
"Comfy_Canvas_NavigationMode": {
"name": "畫布導航模式",
"options": {
"Left-Click Pan (Legacy)": "左鍵拖曳(舊版)",
"Standard (New)": "標準(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
"name": "显示选择工具箱"
},
@@ -329,10 +336,6 @@
},
"tooltip": "選單列位置。在行動裝置上,選單始終顯示於頂端。"
},
"Comfy_Validation_NodeDefs": {
"name": "校验节点定义(慢)",
"tooltip": "推荐给节点开发者。开启后会在 ComfyUI 启动时校验全部节点定义。"
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"
},
@@ -399,10 +402,6 @@
"LiteGraph_Node_TooltipDelay": {
"name": "工具提示延迟"
},
"LiteGraph_Pointer_TrackpadGestures": {
"name": "启用触控板手势",
"tooltip": "此设置为画布启用触控板模式,允许使用双指捏合缩放和拖动。"
},
"LiteGraph_Reroute_SplineOffset": {
"name": "重新路由样条偏移",
"tooltip": "贝塞尔控制点从重新路由中心点的偏移"

View File

@@ -48,6 +48,22 @@ const zProgressWsMessage = z.object({
node: zNodeId
})
const zNodeProgressState = z.object({
value: z.number(),
max: z.number(),
state: z.enum(['pending', 'running', 'finished', 'error']),
node_id: zNodeId,
prompt_id: zPromptId,
display_node_id: zNodeId.optional(),
parent_node_id: zNodeId.optional(),
real_node_id: zNodeId.optional()
})
const zProgressStateWsMessage = z.object({
prompt_id: zPromptId,
nodes: z.record(zNodeId, zNodeProgressState)
})
const zExecutingWsMessage = z.object({
node: zNodeId,
display_node: zNodeId,
@@ -134,6 +150,8 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages
@@ -320,6 +338,7 @@ export const zSystemStats = z.object({
embedded_python: z.boolean(),
comfyui_version: z.string(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),
ram_total: z.number(),
ram_free: z.number()
@@ -449,7 +468,6 @@ const zSettings = z.object({
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(),
'Comfy.Canvas.SelectionToolbox': z.boolean(),
'LiteGraph.Node.TooltipDelay': z.number(),
'Comfy.ComfirmClear': z.boolean(),
'LiteGraph.ContextMenu.Scaling': z.boolean(),
'LiteGraph.Reroute.SplineOffset': z.number(),
'Comfy.Toast.DisableReconnectingToast': z.boolean(),
@@ -457,6 +475,8 @@ const zSettings = z.object({
'Comfy.TutorialCompleted': z.boolean(),
'Comfy.InstalledVersion': z.string().nullable(),
'Comfy.Node.AllowImageSizeDraw': z.boolean(),
'Comfy.Minimap.Visible': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),

View File

@@ -17,6 +17,7 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
@@ -38,6 +39,7 @@ import {
validateComfyNodeDef
} from '@/schemas/nodeDefSchema'
import { useToastStore } from '@/stores/toastStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
@@ -106,7 +108,17 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
/** Binary preview with metadata (node_id, prompt_id) */
b_preview_with_metadata: {
blob: Blob
nodeId: string
parentNodeId: string
displayNodeId: string
realNodeId: string
promptId: string
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
@@ -458,6 +470,33 @@ export class ComfyApi extends EventTarget {
})
this.dispatchCustomEvent('b_preview', imageBlob)
break
case 4:
// PREVIEW_IMAGE_WITH_METADATA
const decoder4 = new TextDecoder()
const metadataLength = view.getUint32(4)
const metadataBytes = event.data.slice(8, 8 + metadataLength)
const metadata = JSON.parse(decoder4.decode(metadataBytes))
const imageData4 = event.data.slice(8 + metadataLength)
let imageMime4 = metadata.image_type
const imageBlob4 = new Blob([imageData4], {
type: imageMime4
})
// Dispatch enhanced preview event with metadata
this.dispatchCustomEvent('b_preview_with_metadata', {
blob: imageBlob4,
nodeId: metadata.node_id,
displayNodeId: metadata.display_node_id,
parentNodeId: metadata.parent_node_id,
realNodeId: metadata.real_node_id,
promptId: metadata.prompt_id
})
// Also dispatch legacy b_preview for backward compatibility
this.dispatchCustomEvent('b_preview', imageBlob4)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
@@ -487,6 +526,7 @@ export class ComfyApi extends EventTarget {
case 'execution_cached':
case 'execution_success':
case 'progress':
case 'progress_state':
case 'executed':
case 'graphChanged':
case 'promptQueued':
@@ -567,31 +607,9 @@ export class ComfyApi extends EventTarget {
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs({ validate = false }: { validate?: boolean } = {}): Promise<
Record<string, ComfyNodeDef>
> {
async getNodeDefs(): 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(
objectInfoUnsafe[key],
/* onError=*/ (errorMessage: string) => {
console.warn(
`Skipping invalid node definition: ${key}. See debug log for more information.`
)
console.debug(errorMessage)
}
)
if (validatedDef !== null) {
objectInfo[key] = validatedDef
}
}
return objectInfo
return await resp.json()
}
/**

View File

@@ -47,6 +47,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -60,6 +61,10 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import {
getNodeByExecutionId,
triggerCallbackOnAllNodes
} from '@/utils/graphTraversalUtil'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -75,6 +80,7 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import {
getAvifMetadata,
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
@@ -110,6 +116,8 @@ type Clipspace = {
images?: any[] | null
selectedIndex: number
img_paste_mode: string
paintedIndex: number
combinedIndex: number
}
export class ComfyApp {
@@ -194,6 +202,8 @@ export class ComfyApp {
/**
* @deprecated Use useExecutionStore().executingNodeId instead
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
*/
get runningNodeId(): NodeId | null {
return useExecutionStore().executingNodeId
@@ -349,13 +359,18 @@ export class ComfyApp {
selectedIndex = node.imageIndex
}
const paintedIndex = selectedIndex + 1
const combinedIndex = selectedIndex + 2
ComfyApp.clipspace = {
widgets: widgets,
imgs: imgs,
original_imgs: orig_imgs,
images: node.images,
selectedIndex: selectedIndex,
img_paste_mode: 'selected' // reset to default im_paste_mode state on copy action
img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action
paintedIndex: paintedIndex,
combinedIndex: combinedIndex
}
ComfyApp.clipspace_return_node = null
@@ -368,6 +383,8 @@ export class ComfyApp {
static pasteFromClipspace(node: LGraphNode) {
if (ComfyApp.clipspace) {
// image paste
const combinedImgSrc =
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src
if (ComfyApp.clipspace.imgs && node.imgs) {
if (node.images && ComfyApp.clipspace.images) {
if (ComfyApp.clipspace['img_paste_mode'] == 'selected') {
@@ -401,6 +418,28 @@ export class ComfyApp {
}
}
// Paste the RGB canvas if paintedindex exists
if (
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.paintedIndex] &&
node.imgs
) {
const paintedImg = new Image()
paintedImg.src =
ComfyApp.clipspace.imgs[ComfyApp.clipspace.paintedIndex].src
node.imgs.push(paintedImg) // Add the RGB canvas to the node's images
}
// Store only combined image inside the node if it exists
if (
ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex] &&
node.imgs &&
combinedImgSrc
) {
const combinedImg = new Image()
combinedImg.src = combinedImgSrc
node.imgs = [combinedImg]
}
if (node.widgets) {
if (ComfyApp.clipspace.images) {
const clip_image =
@@ -635,36 +674,24 @@ export class ComfyApp {
api.addEventListener('executing', () => {
this.graph.setDirtyCanvas(true, false)
// @ts-expect-error fixme ts strict error
this.revokePreviews(this.runningNodeId)
// @ts-expect-error fixme ts strict error
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
const output = this.nodeOutputs[detail.display_node || detail.node]
if (detail.merge && output) {
for (const k in detail.output ?? {}) {
const v = output[k]
if (v instanceof Array) {
output[k] = v.concat(detail.output[k])
} else {
output[k] = detail.output[k]
}
}
} else {
this.nodeOutputs[detail.display_node || detail.node] = detail.output
}
const node = this.graph.getNodeById(detail.display_node || detail.node)
if (node) {
if (node.onExecuted) node.onExecuted(detail.output)
const nodeOutputStore = useNodeOutputStore()
const executionId = String(detail.display_node || detail.node)
nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, {
merge: detail.merge
})
const node = getNodeByExecutionId(this.graph, executionId)
if (node && node.onExecuted) {
node.onExecuted(detail.output)
}
})
api.addEventListener('execution_start', () => {
this.graph.nodes.forEach((node) => {
if (node.onExecutionStart) node.onExecutionStart()
})
triggerCallbackOnAllNodes(this.graph, 'onExecutionStart')
})
api.addEventListener('execution_error', ({ detail }) => {
@@ -689,15 +716,16 @@ export class ComfyApp {
this.canvas.draw(true, true)
})
api.addEventListener('b_preview', ({ detail }) => {
const id = this.runningNodeId
if (id == null) return
const blob = detail
const blobUrl = URL.createObjectURL(blob)
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } =
useNodeOutputStore()
// Ensure clean up if `executing` event is missed.
this.revokePreviews(id)
this.nodePreviewImages[id] = [blobUrl]
revokePreviewsByExecutionId(displayNodeId)
const blobUrl = URL.createObjectURL(blob)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews
setNodePreviewsByExecutionId(displayNodeId, [blobUrl])
})
api.init()
@@ -724,16 +752,12 @@ export class ComfyApp {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
for (const node of graph.nodes) {
node.onGraphConfigured?.()
}
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
for (const node of graph.nodes) {
node.onAfterGraphConfigured?.()
}
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
return r
}
@@ -859,26 +883,33 @@ export class ComfyApp {
private updateVueAppNodeDefs(defs: Record<string, ComfyNodeDefV1>) {
// Frontend only nodes registered by custom nodes.
// Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10
const rawDefs: Record<string, ComfyNodeDefV1> = Object.fromEntries(
Object.entries(LiteGraph.registered_node_types).map(([name, node]) => [
// Only create frontend_only definitions for nodes that don't have backend definitions
const frontendOnlyDefs: Record<string, ComfyNodeDefV1> = {}
for (const [name, node] of Object.entries(
LiteGraph.registered_node_types
)) {
// Skip if we already have a backend definition or system definition
if (name in defs || name in SYSTEM_NODE_DEFS) {
continue
}
frontendOnlyDefs[name] = {
name,
{
name,
display_name: name,
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'custom_nodes.frontend_only',
description: `Frontend only node for ${name}`
} as ComfyNodeDefV1
])
)
display_name: name,
category: node.category || '__frontend_only__',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'custom_nodes.frontend_only',
description: `Frontend only node for ${name}`
} as ComfyNodeDefV1
}
const allNodeDefs = {
...rawDefs,
...frontendOnlyDefs,
...defs,
...SYSTEM_NODE_DEFS
}
@@ -909,12 +940,7 @@ export class ComfyApp {
.join('/')
})
return _.mapValues(
await api.getNodeDefs({
validate: useSettingStore().get('Comfy.Validation.NodeDefs')
}),
(def) => translateNodeDef(def)
)
return _.mapValues(await api.getNodeDefs(), (def) => translateNodeDef(def))
}
/**
@@ -1355,6 +1381,16 @@ export class ComfyApp {
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'image/avif') {
const { workflow, prompt } = await getAvifMetadata(file)
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
@@ -1676,25 +1712,13 @@ export class ComfyApp {
}
}
/**
* Frees memory allocated to image preview blobs for a specific node, by revoking the URLs associated with them.
* @param nodeId ID of the node to revoke all preview images of
*/
revokePreviews(nodeId: NodeId) {
if (!this.nodePreviewImages[nodeId]?.[Symbol.iterator]) return
for (const url of this.nodePreviewImages[nodeId]) {
URL.revokeObjectURL(url)
}
}
/**
* Clean current state
*/
clean() {
this.nodeOutputs = {}
for (const id of Object.keys(this.nodePreviewImages)) {
this.revokePreviews(id)
}
this.nodePreviewImages = {}
const { revokeAllPreviews } = useNodeOutputStore()
revokeAllPreviews()
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null

View File

@@ -44,12 +44,30 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
inputEl?: T
}
/**
* Additional props that can be passed to component widgets.
* These are in addition to the standard props that are always provided:
* - modelValue: The widget's value (handled by v-model)
* - widget: Reference to the widget instance
* - onUpdate:modelValue: The update handler for v-model
*/
export type ComponentWidgetCustomProps = Record<string, unknown>
/**
* Standard props that are handled separately by DomWidget.vue and should be
* omitted when defining custom props for component widgets
*/
export type ComponentWidgetStandardProps =
| 'modelValue'
| 'widget'
| 'onUpdate:modelValue'
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
> extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
@@ -158,6 +176,21 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
override onRemove(): void {
useDomWidgetStore().unregisterWidget(this.id)
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
options: this.options
})
cloned.value = this.value
// Preserve the Y position from the original widget to maintain proper positioning
// when widgets are promoted through subgraph nesting
cloned.y = this.y
return cloned
}
}
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
@@ -177,6 +210,22 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
this.element = obj.element
}
override createCopyForNode(node: LGraphNode): this {
// @ts-expect-error
const cloned: this = new (this.constructor as typeof this)({
node: node,
name: this.name,
type: this.type,
element: this.element, // Include the element!
options: this.options
})
cloned.value = this.value
// Preserve the Y position from the original widget to maintain proper positioning
// when widgets are promoted through subgraph nesting
cloned.y = this.y
return cloned
}
/** Extract DOM widget size info */
override computeLayoutSize(node: LGraphNode) {
if (this.type === 'hidden') {
@@ -222,7 +271,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
export class ComponentWidgetImpl<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V, P>

View File

@@ -0,0 +1,412 @@
import {
type AvifIinfBox,
type AvifIlocBox,
type AvifInfeBox,
ComfyMetadata,
ComfyMetadataTags,
type IsobmffBoxContentRange
} from '@/types/metadataTypes'
const readNullTerminatedString = (
dataView: DataView,
start: number,
end: number
): { str: string; length: number } => {
let length = 0
while (start + length < end && dataView.getUint8(start + length) !== 0) {
length++
}
const str = new TextDecoder('utf-8').decode(
new Uint8Array(dataView.buffer, dataView.byteOffset + start, length)
)
return { str, length: length + 1 } // Include null terminator
}
const parseInfeBox = (dataView: DataView, start: number): AvifInfeBox => {
const version = dataView.getUint8(start)
const flags = dataView.getUint32(start) & 0xffffff
let offset = start + 4
let item_ID: number, item_protection_index: number, item_type: string
if (version >= 2) {
if (version === 2) {
item_ID = dataView.getUint16(offset)
offset += 2
} else {
item_ID = dataView.getUint32(offset)
offset += 4
}
item_protection_index = dataView.getUint16(offset)
offset += 2
item_type = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset, 4)
)
offset += 4
const { str: item_name, length: name_len } = readNullTerminatedString(
dataView,
offset,
dataView.byteLength
)
offset += name_len
const content_type = readNullTerminatedString(
dataView,
offset,
dataView.byteLength
).str
return {
box_header: { size: 0, type: 'infe' }, // Size is dynamic
version,
flags,
item_ID,
item_protection_index,
item_type,
item_name,
content_type
}
}
throw new Error(`Unsupported infe box version: ${version}`)
}
const parseIinfBox = (
dataView: DataView,
range: IsobmffBoxContentRange
): AvifIinfBox => {
if (!range) throw new Error('iinf box not found')
const version = dataView.getUint8(range.start)
const flags = dataView.getUint32(range.start) & 0xffffff
let offset = range.start + 4
const entry_count =
version === 0 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version === 0 ? 2 : 4
const entries: AvifInfeBox[] = []
for (let i = 0; i < entry_count; i++) {
const boxSize = dataView.getUint32(offset)
const boxType = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4)
)
if (boxType === 'infe') {
const infe = parseInfeBox(dataView, offset + 8)
infe.box_header.size = boxSize
entries.push(infe)
}
offset += boxSize
}
return {
box_header: { size: range.end - range.start + 8, type: 'iinf' },
version,
flags,
entry_count,
entries
}
}
const parseIlocBox = (
dataView: DataView,
range: IsobmffBoxContentRange
): AvifIlocBox => {
if (!range) throw new Error('iloc box not found')
const version = dataView.getUint8(range.start)
const flags = dataView.getUint32(range.start) & 0xffffff
let offset = range.start + 4
const sizes = dataView.getUint8(offset++)
const offset_size = (sizes >> 4) & 0x0f
const length_size = sizes & 0x0f
const base_offset_size = (dataView.getUint8(offset) >> 4) & 0x0f
const index_size =
version === 1 || version === 2 ? dataView.getUint8(offset) & 0x0f : 0
offset++
const item_count =
version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version < 2 ? 2 : 4
const items = []
for (let i = 0; i < item_count; i++) {
const item_ID =
version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset)
offset += version < 2 ? 2 : 4
if (version === 1 || version === 2) {
offset += 2 // construction_method
}
const data_reference_index = dataView.getUint16(offset)
offset += 2
const base_offset = base_offset_size > 0 ? dataView.getUint32(offset) : 0 // Simplified
offset += base_offset_size
const extent_count = dataView.getUint16(offset)
offset += 2
const extents = []
for (let j = 0; j < extent_count; j++) {
if ((version === 1 || version === 2) && index_size > 0) {
offset += index_size
}
const extent_offset = dataView.getUint32(offset) // Simplified
offset += offset_size
const extent_length = dataView.getUint32(offset) // Simplified
offset += length_size
extents.push({ extent_offset, extent_length })
}
items.push({
item_ID,
data_reference_index,
base_offset,
extent_count,
extents
})
}
return {
box_header: { size: range.end - range.start + 8, type: 'iloc' },
version,
flags,
offset_size,
length_size,
base_offset_size,
index_size,
item_count,
items
}
}
function findBox(
dataView: DataView,
start: number,
end: number,
type: string
): IsobmffBoxContentRange {
let offset = start
while (offset < end) {
if (offset + 8 > end) break
const boxLength = dataView.getUint32(offset)
const boxType = String.fromCharCode(
...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4)
)
if (boxLength === 0) break
if (boxType === type) {
return { start: offset + 8, end: offset + boxLength }
}
if (offset + boxLength > end) break
offset += boxLength
}
return null
}
function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
const metadata: ComfyMetadata = {}
const dataView = new DataView(buffer)
if (
dataView.getUint32(4) !== 0x66747970 ||
dataView.getUint32(8) !== 0x61766966
) {
console.error('Not a valid AVIF file')
return {}
}
const metaBox = findBox(dataView, 0, dataView.byteLength, 'meta')
if (!metaBox) return {}
const metaBoxContentStart = metaBox.start + 4 // Skip version and flags
const iinfBoxRange = findBox(
dataView,
metaBoxContentStart,
metaBox.end,
'iinf'
)
const iinf = parseIinfBox(dataView, iinfBoxRange)
const exifInfe = iinf.entries.find((e) => e.item_type === 'Exif')
if (!exifInfe) return {}
const ilocBoxRange = findBox(
dataView,
metaBoxContentStart,
metaBox.end,
'iloc'
)
const iloc = parseIlocBox(dataView, ilocBoxRange)
const exifIloc = iloc.items.find((i) => i.item_ID === exifInfe.item_ID)
if (!exifIloc || exifIloc.extents.length === 0) return {}
const exifExtent = exifIloc.extents[0]
const itemData = new Uint8Array(
buffer,
exifExtent.extent_offset,
exifExtent.extent_length
)
let tiffHeaderOffset = -1
for (let i = 0; i < itemData.length - 4; i++) {
if (
(itemData[i] === 0x4d &&
itemData[i + 1] === 0x4d &&
itemData[i + 2] === 0x00 &&
itemData[i + 3] === 0x2a) || // MM*
(itemData[i] === 0x49 &&
itemData[i + 1] === 0x49 &&
itemData[i + 2] === 0x2a &&
itemData[i + 3] === 0x00) // II*
) {
tiffHeaderOffset = i
break
}
}
if (tiffHeaderOffset !== -1) {
const exifData = itemData.subarray(tiffHeaderOffset)
const data: Record<string, any> = parseExifData(exifData)
for (const key in data) {
const value = data[key]
if (typeof value === 'string') {
if (key === 'usercomment') {
try {
const metadataJson = JSON.parse(value)
if (metadataJson.prompt) {
metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt
}
if (metadataJson.workflow) {
metadata[ComfyMetadataTags.WORKFLOW] = metadataJson.workflow
}
} catch (e) {
console.error('Failed to parse usercomment JSON', e)
}
} else {
const [metadataKey, ...metadataValueParts] = value.split(':')
const metadataValue = metadataValueParts.join(':').trim()
if (
metadataKey.toLowerCase() ===
ComfyMetadataTags.PROMPT.toLowerCase() ||
metadataKey.toLowerCase() ===
ComfyMetadataTags.WORKFLOW.toLowerCase()
) {
try {
const jsonValue = JSON.parse(metadataValue)
metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] =
jsonValue
} catch (e) {
console.error(`Failed to parse JSON for ${metadataKey}`, e)
}
}
}
}
}
} else {
console.log('Warning: TIFF header not found in EXIF data.')
}
return metadata
}
// @ts-expect-error fixme ts strict error
export function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)
const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II'
// Function to read 16-bit and 32-bit integers from binary data
// @ts-expect-error fixme ts strict error
function readInt(offset, isLittleEndian, length) {
let arr = exifData.slice(offset, offset + length)
if (length === 2) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16(
0,
isLittleEndian
)
} else if (length === 4) {
return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32(
0,
isLittleEndian
)
}
}
// Read the offset to the first IFD (Image File Directory)
const ifdOffset = readInt(4, isLittleEndian, 4)
// @ts-expect-error fixme ts strict error
function parseIFD(offset) {
const numEntries = readInt(offset, isLittleEndian, 2)
const result = {}
// @ts-expect-error fixme ts strict error
for (let i = 0; i < numEntries; i++) {
const entryOffset = offset + 2 + i * 12
const tag = readInt(entryOffset, isLittleEndian, 2)
const type = readInt(entryOffset + 2, isLittleEndian, 2)
const numValues = readInt(entryOffset + 4, isLittleEndian, 4)
const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4)
// Read the value(s) based on the data type
let value
if (type === 2) {
// ASCII string
value = new TextDecoder('utf-8').decode(
// @ts-expect-error fixme ts strict error
exifData.subarray(valueOffset, valueOffset + numValues - 1)
)
}
// @ts-expect-error fixme ts strict error
result[tag] = value
}
return result
}
// Parse the first IFD
const ifdData = parseIFD(ifdOffset)
return ifdData
}
export function getFromAvifFile(file: File): Promise<Record<string, string>> {
return new Promise<Record<string, string>>((resolve) => {
const reader = new FileReader()
reader.onload = (event) => {
const buffer = event.target?.result as ArrayBuffer
if (!buffer) {
resolve({})
return
}
try {
const comfyMetadata = parseAvifMetadata(buffer)
const result: Record<string, string> = {}
if (comfyMetadata.prompt) {
result.prompt = JSON.stringify(comfyMetadata.prompt)
}
if (comfyMetadata.workflow) {
result.workflow = JSON.stringify(comfyMetadata.workflow)
}
resolve(result)
} catch (e) {
console.error('Parser: Error parsing AVIF metadata:', e)
resolve({})
}
}
reader.onerror = (err) => {
console.error('FileReader: Error reading AVIF file:', err)
resolve({})
}
reader.readAsArrayBuffer(file)
})
}

View File

@@ -1,6 +1,7 @@
import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromAvifFile } from './metadata/avif'
import { getFromFlacFile } from './metadata/flac'
import { getFromPngFile } from './metadata/png'
@@ -13,6 +14,10 @@ export function getFlacMetadata(file: File): Promise<Record<string, string>> {
return getFromFlacFile(file)
}
export function getAvifMetadata(file: File): Promise<Record<string, string>> {
return getFromAvifFile(file)
}
// @ts-expect-error fixme ts strict error
function parseExifData(exifData) {
// Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian)

View File

@@ -21,7 +21,7 @@ export function clone<T>(obj: T): T {
* There are external callers to this function, so we need to keep it for now
*/
export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.graph.nodes, value)
return _applyTextReplacements(app.graph, value)
}
export async function addStylesheet(

View File

@@ -0,0 +1,84 @@
import { register } from 'extendable-media-recorder'
import { connect } from 'extendable-media-recorder-wav-encoder'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
export interface AudioRecordingError {
type: 'permission' | 'not_supported' | 'encoder' | 'recording' | 'unknown'
message: string
originalError?: unknown
}
let isEncoderRegistered: boolean = false
export const useAudioService = () => {
const handleError = (
type: AudioRecordingError['type'],
message: string,
originalError?: unknown
) => {
console.error(`Audio Service Error (${type}):`, message, originalError)
}
const stopAllTracks = (currentStream: MediaStream | null) => {
if (currentStream) {
currentStream.getTracks().forEach((track) => {
track.stop()
})
currentStream = null
}
}
const registerWavEncoder = async (): Promise<void> => {
if (isEncoderRegistered) {
return
}
try {
await register(await connect())
isEncoderRegistered = true
} catch (err) {
if (
err instanceof Error &&
err.message.includes('already an encoder stored')
) {
isEncoderRegistered = true
} else {
handleError('encoder', 'Failed to register WAV encoder', err)
}
}
}
const convertBlobToFileAndSubmit = async (blob: Blob): Promise<string> => {
const name = `recording-${Date.now()}.wav`
const file = new File([blob], name, { type: blob.type || 'audio/wav' })
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'audio')
body.append('type', 'temp')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}`
useToastStore().addAlert(err)
throw new Error(err)
}
const tempAudio = await resp.json()
return `audio/${tempAudio.name} [temp]`
}
return {
// Methods
convertBlobToFileAndSubmit,
registerWavEncoder,
stopAllTracks
}
}

View File

@@ -272,7 +272,7 @@ export const useDialogService = () => {
onSuccess: () => resolve(true)
},
dialogComponentProps: {
closable: false,
closable: true,
onClose: () => resolve(false)
}
})

View File

@@ -32,7 +32,10 @@ import type {
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ComfyApp, app } from '@/scripts/app'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { $el } from '@/scripts/ui'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -87,6 +90,37 @@ export const useLitegraphService = () => {
constructor() {
super(app.graph, subgraph, instanceData)
// Set up event listener for promoted widget registration
subgraph.events.addEventListener('widget-promoted', (event) => {
const { widget } = event.detail
// Only handle DOM widgets
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
const domWidgetStore = useDomWidgetStore()
if (!domWidgetStore.widgetStates.has(widget.id)) {
domWidgetStore.registerWidget(widget)
// Set initial visibility based on whether the widget's node is in the current graph
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (widgetState) {
const currentGraph = canvasStore.getCanvas().graph
widgetState.visible =
currentGraph?.nodes.includes(widget.node) ?? false
}
}
})
// Set up event listener for promoted widget removal
subgraph.events.addEventListener('widget-demoted', (event) => {
const { widget } = event.detail
// Only handle DOM widgets
if (!isDOMWidget(widget) && !isComponentWidget(widget)) return
const domWidgetStore = useDomWidgetStore()
if (domWidgetStore.widgetStates.has(widget.id)) {
domWidgetStore.unregisterWidget(widget.id)
}
})
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
@@ -95,9 +129,15 @@ export const useLitegraphService = () => {
void extensionService.invokeExtensionsAsync('nodeCreated', this)
this.badges.push(
new LGraphBadge({
text: '',
fgColor: '#dad0de',
bgColor: '#b3b'
text: '',
iconOptions: {
unicode: '\ue96e',
fontFamily: 'PrimeIcons',
color: '#ffffff',
fontSize: 12
},
fgColor: '#ffffff',
bgColor: '#3b82f6'
})
)
}
@@ -107,7 +147,11 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
@@ -362,7 +406,11 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
@@ -745,7 +793,7 @@ export const useLitegraphService = () => {
if (isImageNode(this)) {
options.push({
content: 'Open in MaskEditor',
content: 'Open in MaskEditor | Image Canvas',
callback: () => {
ComfyApp.copyToClipspace(this)
// @ts-expect-error fixme ts strict error

View File

@@ -135,6 +135,7 @@ The following table lists ALL stores in the system as of 2025-01-30:
| toastStore.ts | Manages toast notifications | UI |
| userFileStore.ts | Manages user file operations | Files |
| userStore.ts | Manages user data and preferences | User |
| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core |
| widgetStore.ts | Manages widget configurations | Widgets |
| workflowStore.ts | Handles workflow data and operations | Workflows |
| workflowTemplatesStore.ts | Manages workflow templates | Workflows |

View File

@@ -26,6 +26,8 @@ interface CustomDialogComponentProps {
modal?: boolean
position?: DialogPosition
pt?: DialogPassThroughOptions
closeOnEscape?: boolean
dismissableMask?: boolean
}
type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
@@ -62,6 +64,12 @@ export interface ShowDialogOptions {
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
/**
* The key of the currently active (top-most) dialog.
* Only the active dialog can be closed with the ESC key.
*/
const activeKey = ref<string | null>(null)
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
/**
@@ -87,17 +95,27 @@ export const useDialogStore = defineStore('dialog', () => {
if (index !== -1) {
const [dialog] = dialogStack.value.splice(index, 1)
insertDialogByPriority(dialog)
activeKey.value = dialogKey
updateCloseOnEscapeStates()
}
}
function closeDialog(options?: { key: string }) {
const targetDialog = options
? dialogStack.value.find((d) => d.key === options.key)
: dialogStack.value[0]
: dialogStack.value.find((d) => d.key === activeKey.value)
if (!targetDialog) return
targetDialog.dialogComponentProps?.onClose?.()
dialogStack.value.splice(dialogStack.value.indexOf(targetDialog), 1)
const index = dialogStack.value.indexOf(targetDialog)
dialogStack.value.splice(index, 1)
activeKey.value =
dialogStack.value.length > 0
? dialogStack.value[dialogStack.value.length - 1].key
: null
updateCloseOnEscapeStates()
}
function createDialog(options: {
@@ -114,7 +132,7 @@ export const useDialogStore = defineStore('dialog', () => {
dialogStack.value.shift()
}
const dialog: DialogInstance = {
const dialog = {
key: options.key,
visible: true,
title: options.title,
@@ -135,7 +153,6 @@ export const useDialogStore = defineStore('dialog', () => {
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
// @ts-expect-error TODO: fix this
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
@@ -156,10 +173,29 @@ export const useDialogStore = defineStore('dialog', () => {
}
insertDialogByPriority(dialog)
activeKey.value = options.key
updateCloseOnEscapeStates()
return dialog
}
/**
* Ensures only the top-most dialog in the stack can be closed with the Escape key.
* This is necessary because PrimeVue Dialogs do not handle `closeOnEscape` prop
* correctly when multiple dialogs are open.
*/
function updateCloseOnEscapeStates() {
const topDialog = dialogStack.value.find((d) => d.key === activeKey.value)
const topClosable = topDialog?.dialogComponentProps.closable
dialogStack.value.forEach((dialog) => {
dialog.dialogComponentProps = {
...dialog.dialogComponentProps,
closeOnEscape: dialog === topDialog && !!topClosable
}
})
}
function showDialog(options: ShowDialogOptions) {
const dialogKey = options.key || genDialogKey()
@@ -206,6 +242,7 @@ export const useDialogStore = defineStore('dialog', () => {
showDialog,
closeDialog,
showExtensionDialog,
isDialogOpen
isDialogOpen,
activeKey
}
})

View File

@@ -12,6 +12,8 @@ import type {
ExecutionErrorWsMessage,
ExecutionStartWsMessage,
NodeError,
NodeProgressState,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage
} from '@/schemas/apiSchema'
@@ -21,6 +23,10 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { useCanvasStore } from './graphStore'
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
@@ -46,7 +52,97 @@ export const useExecutionStore = defineStore('execution', () => {
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const executingNodeId = ref<NodeId | null>(null)
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
const executionIdToNodeLocatorId = (
nodeId: string | number
): NodeLocatorId => {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
): NodeProgressState => {
if (currentState === undefined) {
return newState
}
const mergedState = { ...currentState }
if (mergedState.state === 'error') {
return mergedState
} else if (newState.state === 'running') {
const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0
const oldPerc =
mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0
if (
mergedState.state !== 'running' ||
oldPerc === 0.0 ||
newPerc < oldPerc
) {
mergedState.value = newState.value
mergedState.max = newState.max
}
mergedState.state = 'running'
}
return mergedState
}
const nodeLocationProgressStates = computed<
Record<NodeLocatorId, NodeProgressState>
>(() => {
const result: Record<NodeLocatorId, NodeProgressState> = {}
const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues
for (const state of Object.values(states)) {
const parts = String(state.display_node_id).split(':')
for (let i = 0; i < parts.length; i++) {
const executionId = parts.slice(0, i + 1).join(':')
const locatorId = executionIdToNodeLocatorId(executionId)
if (!locatorId) continue
result[locatorId] = mergeExecutionProgressStates(
result[locatorId],
state
)
}
}
return result
})
// Easily access all currently executing node IDs
const executingNodeIds = computed<NodeId[]>(() => {
return Object.entries(nodeProgressStates)
.filter(([_, state]) => state.state === 'running')
.map(([nodeId, _]) => nodeId)
})
// @deprecated For backward compatibility - stores the primary executing node ID
const executingNodeId = computed<NodeId | null>(() => {
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
})
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
@@ -93,30 +189,7 @@ export const useExecutionStore = defineStore('execution', () => {
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const subgraph = workflowStore.activeSubgraph
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(
subgraph.rootGraph,
subgraphNodeIds
)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
// This is the progress of the currently executing node, if any
// This is the progress of the currently executing node (for backward compatibility)
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
_executingNodeProgress.value
@@ -150,24 +223,29 @@ export const useExecutionStore = defineStore('execution', () => {
function bindExecutionEvents() {
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
api.addEventListener('execution_interrupted', handleExecutionInterrupted)
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
api.addEventListener('progress_state', handleProgressState)
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
}
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
function unbindExecutionEvents() {
api.removeEventListener('execution_start', handleExecutionStart)
api.removeEventListener('execution_cached', handleExecutionCached)
api.removeEventListener('execution_interrupted', handleExecutionInterrupted)
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
api.removeEventListener('progress_state', handleProgressState)
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
api.removeEventListener('display_component', handleDisplayComponent)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -183,6 +261,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
}
function handleExecutionInterrupted() {
nodeProgressStates.value = {}
}
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
if (!activePrompt.value) return
activePrompt.value.nodes[e.detail.node] = true
@@ -194,19 +276,42 @@ export const useExecutionStore = defineStore('execution', () => {
if (!activePrompt.value) return
if (executingNodeId.value && activePrompt.value) {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
// Update the executing nodes list
if (typeof e.detail !== 'string') {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
if (typeof e.detail === 'string') {
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
} else {
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes } = e.detail
// Revoke previews for nodes that are starting to execute
for (const nodeId in nodes) {
const nodeState = nodes[nodeId]
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
// This node just started executing, revoke its previews
// Note that we're doing the *actual* node id instead of the display node id
// here intentionally. That way, we don't clear the preview every time a new node
// within an expanded graph starts executing.
const { revokePreviewsByExecutionId } = useNodeOutputStore()
revokePreviewsByExecutionId(nodeId)
}
}
// Update the progress states for all nodes
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
const nodeState = nodes[executingNodeId.value]
_executingNodeProgress.value = {
value: nodeState.value,
max: nodeState.max,
prompt_id: nodeState.prompt_id,
node: nodeState.display_node_id || nodeState.node_id
}
}
}
@@ -239,7 +344,7 @@ export const useExecutionStore = defineStore('execution', () => {
const { nodeId, text } = e.detail
if (!text || !nodeId) return
// Handle hierarchical node IDs for subgraphs
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -250,7 +355,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
const { node_id: nodeId, component, props = {} } = e.detail
// Handle hierarchical node IDs for subgraphs
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -290,6 +395,18 @@ export const useExecutionStore = defineStore('execution', () => {
)
}
/**
* Convert a NodeLocatorId to an execution context ID
* @param locatorId The NodeLocatorId
* @returns The execution ID or null if conversion fails
*/
const nodeLocatorIdToExecutionId = (
locatorId: NodeLocatorId | string
): string | null => {
const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId)
return executionId
}
return {
isIdle,
clientId,
@@ -310,9 +427,13 @@ export const useExecutionStore = defineStore('execution', () => {
*/
lastExecutionError,
/**
* The id of the node that is currently being executed
* The id of the node that is currently being executed (backward compatibility)
*/
executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds,
/**
* The prompt that is currently being executed
*/
@@ -330,17 +451,25 @@ export const useExecutionStore = defineStore('execution', () => {
*/
executionProgress,
/**
* The node that is currently being executed
* The node that is currently being executed (backward compatibility)
*/
executingNode,
/**
* The progress of the executing node (if the node reports progress)
* The progress of the executing node (backward compatibility)
*/
executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates,
nodeLocationProgressStates,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress
_executingNodeProgress,
// NodeLocatorId conversion helpers
executionIdToNodeLocatorId,
nodeLocatorIdToExecutionId
}
})

View File

@@ -8,6 +8,9 @@ import {
} from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
@@ -22,17 +25,22 @@ const createOutputs = (
}
}
interface SetOutputOptions {
merge?: boolean
}
export const useNodeOutputStore = defineStore('nodeOutput', () => {
const getNodeId = (node: LGraphNode): string => node.id.toString()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore()
function getNodeOutputs(
node: LGraphNode
): ExecutedWsMessage['output'] | undefined {
return app.nodeOutputs[getNodeId(node)]
return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)]
}
function getNodePreviews(node: LGraphNode): string[] | undefined {
return app.nodePreviewImages[getNodeId(node)]
return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)]
}
/**
@@ -86,6 +94,35 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
})
}
/**
* Internal function to set outputs by NodeLocatorId.
* Handles the merge logic when needed.
*/
function setOutputsByLocatorId(
nodeLocatorId: NodeLocatorId,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
if (options.merge) {
const existingOutput = app.nodeOutputs[nodeLocatorId]
if (existingOutput && outputs) {
for (const k in outputs) {
const existingValue = existingOutput[k]
const newValue = (outputs as Record<NodeLocatorId, any>)[k]
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
existingOutput[k] = existingValue.concat(newValue)
} else {
existingOutput[k] = newValue
}
}
return
}
}
app.nodeOutputs[nodeLocatorId] = outputs
}
function setNodeOutputs(
node: LGraphNode,
filenames: string | string[] | ResultItem,
@@ -96,24 +133,149 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
if (!filenames || !node) return
const nodeId = getNodeId(node)
if (typeof filenames === 'string') {
app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated)
setNodeOutputsByNodeId(
node.id,
createOutputs([filenames], folder, isAnimated)
)
} else if (!Array.isArray(filenames)) {
app.nodeOutputs[nodeId] = filenames
setNodeOutputsByNodeId(node.id, filenames)
} else {
const resultItems = createOutputs(filenames, folder, isAnimated)
if (!resultItems?.images?.length) return
app.nodeOutputs[nodeId] = resultItems
setNodeOutputsByNodeId(node.id, resultItems)
}
}
/**
* Set node outputs by execution ID (hierarchical ID from backend).
* Converts the execution ID to a NodeLocatorId before storing.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @param outputs - The outputs to store
* @param options - Options for setting outputs
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
*/
function setNodeOutputsByExecutionId(
executionId: string,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node outputs by node ID.
* Uses the current graph context to create the appropriate NodeLocatorId.
*
* @param nodeId - The node ID
* @param outputs - The outputs to store
* @param options - Options for setting outputs
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
*/
function setNodeOutputsByNodeId(
nodeId: string | number,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (!nodeLocatorId) return
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node preview images by execution ID (hierarchical ID from backend).
* Converts the execution ID to a NodeLocatorId before storing.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @param previewImages - Array of preview image URLs to store
*/
function setNodePreviewsByExecutionId(
executionId: string,
previewImages: string[]
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
app.nodePreviewImages[nodeLocatorId] = previewImages
}
/**
* Set node preview images by node ID.
* Uses the current graph context to create the appropriate NodeLocatorId.
*
* @param nodeId - The node ID
* @param previewImages - Array of preview image URLs to store
*/
function setNodePreviewsByNodeId(
nodeId: string | number,
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
app.nodePreviewImages[nodeLocatorId] = previewImages
}
/**
* Revoke preview images by execution ID.
* Frees memory allocated to image preview blobs by revoking the URLs.
*
* @param executionId - The execution ID
*/
function revokePreviewsByExecutionId(executionId: string) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
revokePreviewsByLocatorId(nodeLocatorId)
}
/**
* Revoke preview images by node locator ID.
* Frees memory allocated to image preview blobs by revoking the URLs.
*
* @param nodeLocatorId - The node locator ID
*/
function revokePreviewsByLocatorId(nodeLocatorId: NodeLocatorId) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (!previews?.[Symbol.iterator]) return
for (const url of previews) {
URL.revokeObjectURL(url)
}
delete app.nodePreviewImages[nodeLocatorId]
}
/**
* Revoke all preview images.
* Frees memory allocated to all image preview blobs.
*/
function revokeAllPreviews() {
for (const nodeLocatorId of Object.keys(app.nodePreviewImages)) {
const previews = app.nodePreviewImages[nodeLocatorId]
if (!previews?.[Symbol.iterator]) continue
for (const url of previews) {
URL.revokeObjectURL(url)
}
}
app.nodePreviewImages = {}
}
return {
getNodeOutputs,
getNodeImageUrls,
getNodePreviews,
setNodeOutputs,
setNodeOutputsByExecutionId,
setNodeOutputsByNodeId,
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
revokePreviewsByExecutionId,
revokeAllPreviews,
getPreviewParam
}
})

View File

@@ -23,6 +23,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
// Create a new node if it doesn't exist
found = {
label: segment,
key: segment,
items: []
}
currentLevel.push(found)

View File

@@ -251,11 +251,41 @@ export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
} as ComfyNodeDefV1)
}
/**
* Defines a filter for node definitions in the node library.
* Filters are applied in a single pass to determine node visibility.
*/
export interface NodeDefFilter {
/**
* Unique identifier for the filter.
* Convention: Use dot notation like 'core.deprecated' or 'extension.myfilter'
*/
id: string
/**
* Display name for the filter (used in UI/debugging).
*/
name: string
/**
* Optional description explaining what the filter does.
*/
description?: string
/**
* The filter function that returns true if the node should be visible.
* @param nodeDef - The node definition to evaluate
* @returns true if the node should be visible, false to hide it
*/
predicate: (nodeDef: ComfyNodeDefImpl) => boolean
}
export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefsByName = ref<Record<string, ComfyNodeDefImpl>>({})
const nodeDefsByDisplayName = ref<Record<string, ComfyNodeDefImpl>>({})
const showDeprecated = ref(false)
const showExperimental = ref(false)
const nodeDefFilters = ref<NodeDefFilter[]>([])
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
const nodeDataTypes = computed(() => {
@@ -270,13 +300,11 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
return types
})
const visibleNodeDefs = computed(() =>
nodeDefs.value.filter(
(nodeDef: ComfyNodeDefImpl) =>
(showDeprecated.value || !nodeDef.deprecated) &&
(showExperimental.value || !nodeDef.experimental)
const visibleNodeDefs = computed(() => {
return nodeDefs.value.filter((nodeDef) =>
nodeDefFilters.value.every((filter) => filter.predicate(nodeDef))
)
)
})
const nodeSearchService = computed(
() => new NodeSearchService(visibleNodeDefs.value)
)
@@ -306,15 +334,74 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
// Frontend-only nodes don't have nodeDef
// @ts-expect-error Optional chaining used in index
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
const nodeTypeName = node.constructor?.nodeData?.name
if (!nodeTypeName) return null
const nodeDef = nodeDefsByName.value[nodeTypeName] ?? null
return nodeDef
}
/**
* Registers a node definition filter.
* @param filter - The filter to register
*/
function registerNodeDefFilter(filter: NodeDefFilter) {
nodeDefFilters.value = [...nodeDefFilters.value, filter]
}
/**
* Unregisters a node definition filter by ID.
* @param id - The ID of the filter to remove
*/
function unregisterNodeDefFilter(id: string) {
nodeDefFilters.value = nodeDefFilters.value.filter((f) => f.id !== id)
}
/**
* Register the core node definition filters.
*/
function registerCoreNodeDefFilters() {
// Deprecated nodes filter
registerNodeDefFilter({
id: 'core.deprecated',
name: 'Hide Deprecated Nodes',
description: 'Hides nodes marked as deprecated unless explicitly enabled',
predicate: (nodeDef) => showDeprecated.value || !nodeDef.deprecated
})
// Experimental nodes filter
registerNodeDefFilter({
id: 'core.experimental',
name: 'Hide Experimental Nodes',
description:
'Hides nodes marked as experimental unless explicitly enabled',
predicate: (nodeDef) => showExperimental.value || !nodeDef.experimental
})
// Subgraph nodes filter
// @todo Remove this filter when subgraph v2 is released
registerNodeDefFilter({
id: 'core.subgraph',
name: 'Hide Subgraph Nodes',
description:
'Temporarily hides subgraph nodes from node library and search',
predicate: (nodeDef) => {
// Hide subgraph nodes (identified by category='subgraph' and python_module='nodes')
return !(
nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes'
)
}
})
}
// Register core filters on store initialization
registerCoreNodeDefFilters()
return {
nodeDefsByName,
nodeDefsByDisplayName,
showDeprecated,
showExperimental,
nodeDefFilters,
nodeDefs,
nodeDataTypes,
@@ -324,7 +411,9 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode
fromLGraphNode,
registerNodeDefFilter,
unregisterNodeDefFilter
}
})

View File

@@ -14,6 +14,8 @@ import type {
import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
// Task type used in the API.
export type APITaskType = 'queue' | 'history'
@@ -377,7 +379,18 @@ export class TaskItemImpl {
}
await app.loadGraphData(toRaw(this.workflow))
if (this.outputs) {
app.nodeOutputs = toRaw(this.outputs)
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(this.outputs)
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
}

View File

@@ -0,0 +1,138 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
const systemStatsStore = useSystemStatsStore()
const frontendVersion = computed(() => config.app_version)
const backendVersion = computed(
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
)
const requiredFrontendVersion = computed(
() =>
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
// We don't warn about frontend being newer than backend
// Only warn when frontend is outdated (behind required version)
return false
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
})
const versionKey = computed(() => {
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
// All version mismatch dismissals are stored in a single object for clean localStorage organization
const dismissalStorage = useStorage(
'comfy.versionMismatch.dismissals',
{} as Record<string, number>,
localStorage,
{
serializer: {
read: (value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
},
write: (value: Record<string, number>) => JSON.stringify(value)
}
}
)
const isDismissed = computed(() => {
if (!versionKey.value) return false
const dismissedUntil = dismissalStorage.value[versionKey.value]
if (!dismissedUntil) return false
// Check if dismissal has expired
return Date.now() < dismissedUntil
})
const shouldShowWarning = computed(() => {
return hasVersionMismatch.value && !isDismissed.value
})
const warningMessage = computed(() => {
if (isFrontendOutdated.value) {
return {
type: 'outdated' as const,
frontendVersion: frontendVersion.value,
requiredVersion: requiredFrontendVersion.value
}
}
return null
})
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
}
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
}
}
async function initialize() {
await checkVersionCompatibility()
}
return {
frontendVersion,
backendVersion,
requiredFrontendVersion,
hasVersionMismatch,
shouldShowWarning,
warningMessage,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,
dismissWarning,
initialize
}
}
)

View File

@@ -4,10 +4,18 @@ import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId,
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
@@ -163,6 +171,15 @@ export interface WorkflowStore {
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -473,7 +490,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
// Parse the execution ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// Start from the root graph
@@ -488,6 +505,136 @@ export const useWorkflowStore = defineStore('workflow', () => {
watch(activeWorkflow, updateActiveGraph)
/**
* Convert a node ID to a NodeLocatorId
* @param nodeId The local node ID
* @param subgraph The subgraph containing the node (defaults to active subgraph)
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeIdToNodeLocatorId = (
nodeId: NodeId,
subgraph?: Subgraph
): NodeLocatorId => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return String(nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
/**
* Convert a NodeLocatorId to an execution ID for a specific context
* @param locatorId The NodeLocatorId
* @param targetSubgraph The subgraph context (defaults to active subgraph)
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
if (!parsed) return null
const { subgraphUuid, localNodeId } = parsed
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return String(localNodeId)
}
// Find the path from root to the subgraph with this UUID
const findSubgraphPath = (
graph: LGraph | Subgraph,
targetUuid: string,
path: NodeId[] = []
): NodeId[] | null => {
if (isSubgraph(graph) && graph.id === targetUuid) {
return path
}
for (const node of graph._nodes) {
if (node.isSubgraphNode() && node.subgraph) {
const result = findSubgraphPath(node.subgraph, targetUuid, [
...path,
node.id
])
if (result) return result
}
}
return null
}
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
if (!path) return null
// If we have a target subgraph, check if the path goes through it
if (
targetSubgraph &&
!path.some((_, idx) => {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs[subgraphs.length - 1] === targetSubgraph
})
) {
return null
}
return createNodeExecutionId([...path, localNodeId])
}
return {
activeWorkflow,
isActive,
@@ -514,7 +661,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}
}) satisfies () => WorkflowStore

View File

@@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
export type { InputSpec } from '@/schemas/nodeDefSchema'
export type {
NodeLocatorId,
NodeExecutionId,
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from './nodeIdentification'
export type {
EmbeddingsResponse,
ExtensionsResponse,

View File

@@ -85,3 +85,57 @@ export type GltfJsonData = {
* Null if the box was not found.
*/
export type IsobmffBoxContentRange = { start: number; end: number } | null
export type AvifInfeBox = {
box_header: {
size: number
type: 'infe'
}
version: number
flags: number
item_ID: number
item_protection_index: number
item_type: string
item_name: string
content_type?: string
content_encoding?: string
}
export type AvifIinfBox = {
box_header: {
size: number
type: 'iinf'
}
version: number
flags: number
entry_count: number
entries: AvifInfeBox[]
}
export type AvifIlocItemExtent = {
extent_offset: number
extent_length: number
}
export type AvifIlocItem = {
item_ID: number
data_reference_index: number
base_offset: number
extent_count: number
extents: AvifIlocItemExtent[]
}
export type AvifIlocBox = {
box_header: {
size: number
type: 'iloc'
}
version: number
flags: number
offset_size: number
length_size: number
base_offset_size: number
index_size: number
item_count: number
items: AvifIlocItem[]
}

View File

@@ -0,0 +1,123 @@
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
/**
* A globally unique identifier for nodes that maintains consistency across
* multiple instances of the same subgraph.
*
* Format:
* - For subgraph nodes: `<immediate-contained-subgraph-uuid>:<local-node-id>`
* - For root graph nodes: `<local-node-id>`
*
* Examples:
* - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph)
* - "456" (node in root graph)
*
* Unlike execution IDs which change based on the instance path,
* NodeLocatorId remains the same for all instances of a particular node.
*/
export type NodeLocatorId = string
/**
* An execution identifier representing a node's position in nested subgraphs.
* Also known as ExecutionId in some contexts.
*
* Format: Colon-separated path of node IDs
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
*/
export type NodeExecutionId = string
/**
* Type guard to check if a value is a NodeLocatorId
*/
export function isNodeLocatorId(value: unknown): value is NodeLocatorId {
if (typeof value !== 'string') return false
// Check if it's a simple node ID (root graph node)
const parts = value.split(':')
if (parts.length === 1) {
// Simple node ID - must be non-empty
return value.length > 0
}
// Check for UUID:nodeId format
if (parts.length !== 2) return false
// Check that node ID part is not empty
if (!parts[1]) return false
// Basic UUID format check (8-4-4-4-12 hex characters)
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
return uuidPattern.test(parts[0])
}
/**
* Type guard to check if a value is a NodeExecutionId
*/
export function isNodeExecutionId(value: unknown): value is NodeExecutionId {
if (typeof value !== 'string') return false
// Must contain at least one colon to be an execution ID
return value.includes(':')
}
/**
* Parse a NodeLocatorId into its components
* @param id The NodeLocatorId to parse
* @returns The subgraph UUID and local node ID, or null if invalid
*/
export function parseNodeLocatorId(
id: string
): { subgraphUuid: string | null; localNodeId: NodeId } | null {
if (!isNodeLocatorId(id)) return null
const parts = id.split(':')
if (parts.length === 1) {
// Simple node ID (root graph)
return {
subgraphUuid: null,
localNodeId: isNaN(Number(id)) ? id : Number(id)
}
}
const [subgraphUuid, localNodeId] = parts
return {
subgraphUuid,
localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId)
}
}
/**
* Create a NodeLocatorId from components
* @param subgraphUuid The UUID of the immediate containing subgraph
* @param localNodeId The local node ID within that subgraph
* @returns A properly formatted NodeLocatorId
*/
export function createNodeLocatorId(
subgraphUuid: string,
localNodeId: NodeId
): NodeLocatorId {
return `${subgraphUuid}:${localNodeId}`
}
/**
* Parse a NodeExecutionId into its component node IDs
* @param id The NodeExecutionId to parse
* @returns Array of node IDs from root to target, or null if not an execution ID
*/
export function parseNodeExecutionId(id: string): NodeId[] | null {
if (!isNodeExecutionId(id)) return null
return id
.split(':')
.map((part) => (isNaN(Number(part)) ? part : Number(part)))
}
/**
* Create a NodeExecutionId from an array of node IDs
* @param nodeIds Array of node IDs from root to target
* @returns A properly formatted NodeExecutionId
*/
export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
return nodeIds.join(':')
}

View File

@@ -40,7 +40,7 @@ function rgbToHsl({ r, g, b }: RGB): HSL {
return { h, s, l }
}
function hexToRgb(hex: string): RGB {
export function hexToRgb(hex: string): RGB {
let r = 0,
g = 0,
b = 0

View File

@@ -39,7 +39,11 @@ export function trimJsonExt(path?: string) {
export function highlightQuery(text: string, query: string) {
if (!query) return text
const regex = new RegExp(`(${query})`, 'gi')
// Escape special regex characters in the query string
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escapedQuery})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
}

View File

@@ -0,0 +1,353 @@
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
/**
* Parses an execution ID into its component parts.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of node IDs in the path, or null if invalid
*/
export function parseExecutionId(executionId: string): string[] | null {
if (!executionId || typeof executionId !== 'string') return null
return executionId.split(':').filter((part) => part.length > 0)
}
/**
* Extracts the local node ID from an execution ID.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns The local node ID or null if invalid
*/
export function getLocalNodeIdFromExecutionId(
executionId: string
): string | null {
const parts = parseExecutionId(executionId)
return parts ? parts[parts.length - 1] : null
}
/**
* Extracts the subgraph path from an execution ID.
*
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns Array of subgraph node IDs (excluding the final node ID), or empty array
*/
export function getSubgraphPathFromExecutionId(executionId: string): string[] {
const parts = parseExecutionId(executionId)
return parts ? parts.slice(0, -1) : []
}
/**
* Visits each node in a graph (non-recursive, single level).
*
* @param graph - The graph to visit nodes from
* @param visitor - Function called for each node
*/
export function visitGraphNodes(
graph: LGraph | Subgraph,
visitor: (node: LGraphNode) => void
): void {
for (const node of graph.nodes) {
visitor(node)
}
}
/**
* Traverses a path of subgraphs to reach a target graph.
*
* @param startGraph - The graph to start from
* @param path - Array of subgraph node IDs to traverse
* @returns The target graph or null if path is invalid
*/
export function traverseSubgraphPath(
startGraph: LGraph | Subgraph,
path: string[]
): LGraph | Subgraph | null {
let currentGraph: LGraph | Subgraph = startGraph
for (const nodeId of path) {
const node = currentGraph.getNodeById(nodeId)
if (!node?.isSubgraphNode?.() || !node.subgraph) return null
currentGraph = node.subgraph
}
return currentGraph
}
/**
* Traverses all nodes in a graph hierarchy (including subgraphs) and invokes
* a callback on each node that has the specified property.
*
* @param graph - The root graph to start traversal from
* @param callbackProperty - The name of the callback property to invoke on each node
*/
export function triggerCallbackOnAllNodes(
graph: LGraph | Subgraph,
callbackProperty: keyof LGraphNode
): void {
forEachNode(graph, (node) => {
const callback = node[callbackProperty]
if (typeof callback === 'function') {
callback.call(node)
}
})
}
/**
* Maps a function over all nodes in a graph hierarchy (including subgraphs).
* This is a pure functional traversal that doesn't mutate the graph.
*
* @param graph - The root graph to traverse
* @param mapFn - Function to apply to each node
* @returns Array of mapped results (excluding undefined values)
*/
export function mapAllNodes<T>(
graph: LGraph | Subgraph,
mapFn: (node: LGraphNode) => T | undefined
): T[] {
const results: T[] = []
visitGraphNodes(graph, (node) => {
// Recursively map over subgraphs first
if (node.isSubgraphNode?.() && node.subgraph) {
results.push(...mapAllNodes(node.subgraph, mapFn))
}
// Apply map function to current node
const result = mapFn(node)
if (result !== undefined) {
results.push(result)
}
})
return results
}
/**
* Executes a side-effect function on all nodes in a graph hierarchy.
* This is for operations that modify nodes or perform side effects.
*
* @param graph - The root graph to traverse
* @param fn - Function to execute on each node
*/
export function forEachNode(
graph: LGraph | Subgraph,
fn: (node: LGraphNode) => void
): void {
visitGraphNodes(graph, (node) => {
// Recursively process subgraphs first
if (node.isSubgraphNode?.() && node.subgraph) {
forEachNode(node.subgraph, fn)
}
// Execute function on current node
fn(node)
})
}
/**
* Collects all nodes in a graph hierarchy (including subgraphs) into a flat array.
*
* @param graph - The root graph to collect nodes from
* @param filter - Optional filter function to include only specific nodes
* @returns Array of all nodes in the graph hierarchy
*/
export function collectAllNodes(
graph: LGraph | Subgraph,
filter?: (node: LGraphNode) => boolean
): LGraphNode[] {
return mapAllNodes(graph, (node) => {
if (!filter || filter(node)) {
return node
}
return undefined
})
}
/**
* Finds a node by ID anywhere in the graph hierarchy.
*
* @param graph - The root graph to search
* @param nodeId - The ID of the node to find
* @returns The node if found, null otherwise
*/
export function findNodeInHierarchy(
graph: LGraph | Subgraph,
nodeId: string | number
): LGraphNode | null {
// Check current graph
const node = graph.getNodeById(nodeId)
if (node) return node
// Search in subgraphs
for (const node of graph.nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
const found = findNodeInHierarchy(node.subgraph, nodeId)
if (found) return found
}
}
return null
}
/**
* Find a subgraph by its UUID anywhere in the graph hierarchy.
*
* @param graph - The root graph to search
* @param targetUuid - The UUID of the subgraph to find
* @returns The subgraph if found, null otherwise
*/
export function findSubgraphByUuid(
graph: LGraph | Subgraph,
targetUuid: string
): Subgraph | null {
// Check all nodes in the current graph
for (const node of graph._nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
if (node.subgraph.id === targetUuid) {
return node.subgraph
}
// Recursively search in nested subgraphs
const found = findSubgraphByUuid(node.subgraph, targetUuid)
if (found) return found
}
}
return null
}
/**
* Get a node by its execution ID from anywhere in the graph hierarchy.
* Execution IDs use hierarchical format like "123:456:789" for nested nodes.
*
* @param rootGraph - The root graph to search from
* @param executionId - The execution ID (e.g., "123:456:789" or "789")
* @returns The node if found, null otherwise
*/
export function getNodeByExecutionId(
rootGraph: LGraph,
executionId: string
): LGraphNode | null {
if (!rootGraph) return null
const localNodeId = getLocalNodeIdFromExecutionId(executionId)
if (!localNodeId) return null
const subgraphPath = getSubgraphPathFromExecutionId(executionId)
// If no subgraph path, it's in the root graph
if (subgraphPath.length === 0) {
return rootGraph.getNodeById(localNodeId) || null
}
// Traverse to the target subgraph
const targetGraph = traverseSubgraphPath(rootGraph, subgraphPath)
if (!targetGraph) return null
// Get the node from the target graph
return targetGraph.getNodeById(localNodeId) || null
}
/**
* Get a node by its locator ID from anywhere in the graph hierarchy.
* Locator IDs use UUID format like "uuid:nodeId" for subgraph nodes.
*
* @param rootGraph - The root graph to search from
* @param locatorId - The locator ID (e.g., "uuid:123" or "123")
* @returns The node if found, null otherwise
*/
export function getNodeByLocatorId(
rootGraph: LGraph,
locatorId: NodeLocatorId | string
): LGraphNode | null {
if (!rootGraph) return null
const parsedIds = parseNodeLocatorId(locatorId)
if (!parsedIds) return null
const { subgraphUuid, localNodeId } = parsedIds
// If no subgraph UUID, it's in the root graph
if (!subgraphUuid) {
return rootGraph.getNodeById(localNodeId) || null
}
// Find the subgraph with the matching UUID
const targetSubgraph = findSubgraphByUuid(rootGraph, subgraphUuid)
if (!targetSubgraph) return null
return targetSubgraph.getNodeById(localNodeId) || null
}
/**
* Finds the root graph from any graph in the hierarchy.
*
* @param graph - Any graph or subgraph in the hierarchy
* @returns The root graph
*/
export function getRootGraph(graph: LGraph | Subgraph): LGraph | Subgraph {
let current: LGraph | Subgraph = graph
while ('rootGraph' in current && current.rootGraph) {
current = current.rootGraph
}
return current
}
/**
* Applies a function to all nodes whose type matches a subgraph ID.
* Operates on the entire graph hierarchy starting from the root.
*
* @param rootGraph - The root graph to search in
* @param subgraphId - The ID/type of the subgraph to match nodes against
* @param fn - Function to apply to each matching node
*/
export function forEachSubgraphNode(
rootGraph: LGraph | Subgraph | null | undefined,
subgraphId: string | null | undefined,
fn: (node: LGraphNode) => void
): void {
if (!rootGraph || !subgraphId) return
forEachNode(rootGraph, (node) => {
if (node.type === subgraphId) {
fn(node)
}
})
}
/**
* Maps a function over all nodes whose type matches a subgraph ID.
* Operates on the entire graph hierarchy starting from the root.
*
* @param rootGraph - The root graph to search in
* @param subgraphId - The ID/type of the subgraph to match nodes against
* @param mapFn - Function to apply to each matching node
* @returns Array of mapped results
*/
export function mapSubgraphNodes<T>(
rootGraph: LGraph | Subgraph | null | undefined,
subgraphId: string | null | undefined,
mapFn: (node: LGraphNode) => T
): T[] {
if (!rootGraph || !subgraphId) return []
return mapAllNodes(rootGraph, (node) => {
if (node.type === subgraphId) {
return mapFn(node)
}
return undefined
})
}
/**
* Gets all non-IO nodes from a subgraph (excludes SubgraphInputNode and SubgraphOutputNode).
* These are the user-created nodes that can be safely removed when clearing a subgraph.
*
* @param subgraph - The subgraph to get non-IO nodes from
* @returns Array of non-IO nodes (user-created nodes)
*/
export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] {
return subgraph.nodes.filter((node) => !isSubgraphIoNode(node))
}

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