mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 16:40:05 +00:00
Merge branch 'main' into manager/menu-items-migration
This commit is contained in:
184
src/assets/icons/README.md
Normal file
184
src/assets/icons/README.md
Normal 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.
|
||||
7
src/assets/icons/custom/workflow.svg
Normal file
7
src/assets/icons/custom/workflow.svg
Normal 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 |
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
215
src/components/breadcrumb/SubgraphBreadcrumbItem.vue
Normal file
215
src/components/breadcrumb/SubgraphBreadcrumbItem.vue
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
88
src/components/graph/MiniMap.vue
Normal file
88
src/components/graph/MiniMap.vue
Normal 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>
|
||||
@@ -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>(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
19
src/components/sidebar/SidebarBottomPanelToggleButton.vue
Normal file
19
src/components/sidebar/SidebarBottomPanelToggleButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
136
src/composables/canvas/useCanvasTransformSync.ts
Normal file
136
src/composables/canvas/useCanvasTransformSync.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
64
src/composables/element/useOverflowObserver.ts
Normal file
64
src/composables/element/useOverflowObserver.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/composables/graph/useCanvasInteractions.ts
Normal file
59
src/composables/graph/useCanvasInteractions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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] || []
|
||||
}
|
||||
|
||||
@@ -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 || ''))
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
94
src/composables/useFrontendVersionMismatchWarning.ts
Normal file
94
src/composables/useFrontendVersionMismatchWarning.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
685
src/composables/useMinimap.ts
Normal file
685
src/composables/useMinimap.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"supports_preview_metadata": false
|
||||
"supports_preview_metadata": true
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
29
src/extensions/core/maskEditorLayerFilenames.ts
Normal file
29
src/extensions/core/maskEditorLayerFilenames.ts
Normal 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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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é"
|
||||
},
|
||||
|
||||
@@ -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 d’exé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 l’enregistrement",
|
||||
"status": "Statut",
|
||||
"stopRecording": "Arrêter l’enregistrement",
|
||||
"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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "選択したノードのマスクエディタを開く"
|
||||
},
|
||||
|
||||
@@ -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へようこそ"
|
||||
|
||||
@@ -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": "リルート中心点からのベジエ制御点のオフセット"
|
||||
|
||||
@@ -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": "다음 열린 워크플로"
|
||||
|
||||
@@ -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에 오신 것을 환영합니다"
|
||||
|
||||
@@ -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": "경유점 중심에서 베지어 제어점까지의 오프셋"
|
||||
|
||||
@@ -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": "Открыть редактор масок для выбранной ноды"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "Смещение контрольной точки Безье от центральной точки перераспределения"
|
||||
|
||||
@@ -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 編輯器"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "貝茲控制點相對於重導中心點的偏移量"
|
||||
|
||||
@@ -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": "打开选中节点的遮罩编辑器"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "贝塞尔控制点从重新路由中心点的偏移"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
412
src/scripts/metadata/avif.ts
Normal file
412
src/scripts/metadata/avif.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
84
src/services/audioService.ts
Normal file
84
src/services/audioService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,7 @@ export const useDialogService = () => {
|
||||
onSuccess: () => resolve(true)
|
||||
},
|
||||
dialogComponentProps: {
|
||||
closable: false,
|
||||
closable: true,
|
||||
onClose: () => resolve(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
138
src/stores/versionCompatibilityStore.ts
Normal file
138
src/stores/versionCompatibilityStore.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
123
src/types/nodeIdentification.ts
Normal file
123
src/types/nodeIdentification.ts
Normal 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(':')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>')
|
||||
}
|
||||
|
||||
|
||||
353
src/utils/graphTraversalUtil.ts
Normal file
353
src/utils/graphTraversalUtil.ts
Normal 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
Reference in New Issue
Block a user