Compare commits

..

22 Commits

Author SHA1 Message Date
Chenlei Hu
5e6e34cfd3 Fix regex handling of folderName 2025-03-17 15:05:24 -04:00
Chenlei Hu
2a445f3f94 workaround placeholder filename 2025-03-17 14:49:21 -04:00
Chenlei Hu
f8dcb915aa nit 2025-03-17 14:12:49 -04:00
Chenlei Hu
11925ce345 Create folder support 2025-03-17 14:09:07 -04:00
Chenlei Hu
90053058ba [Refactor] Support handleAddFolder in TreeExplorer (#3101) 2025-03-17 14:08:23 -04:00
Chenlei Hu
b36f748a78 [nit] Remove unused provide in TreeExplorer (#3100) 2025-03-17 12:26:35 -04:00
Chenlei Hu
d57d12b426 [Refactor] Handle rename in TreeExplorer (#3099) 2025-03-17 12:26:26 -04:00
Christian Byrne
bd1be28478 [Manager] Add progress queue dialog (#3091)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 12:16:36 -04:00
Christian Byrne
891e18af8e [Manager] Improve node pack card header style (#3098) 2025-03-17 12:15:34 -04:00
Chenlei Hu
1610d06cd1 [Refactor] Accept single root node in TreeExplorer (#3088) 2025-03-17 10:52:06 -04:00
Comfy Org PR Bot
e3c7bbf966 1.14.0 (#3097)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-17 10:41:08 -04:00
Christian Byrne
0bfbbe838f [Manager] Remove shadows on version selector list (#3094) 2025-03-17 10:39:44 -04:00
Christian Byrne
c82fe80716 Allow rectangular virtual grid items (#3093) 2025-03-17 10:39:02 -04:00
Christian Byrne
ad98bcb87c [Manager] Fix registry search results flashing screen (#3092) 2025-03-17 10:38:08 -04:00
Christian Byrne
652ea15e8b Allow footer component and position props in dialogs (#3090) 2025-03-17 10:37:19 -04:00
Christian Byrne
a7a8cc633b [Manager] Improve node pack card design (#3089) 2025-03-17 10:36:35 -04:00
Comfy Org PR Bot
d23aec4ceb [chore] Update litegraph to 0.10.9 (#3095)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-18 00:21:02 +11:00
Chenlei Hu
8db088b27a Remove duplicated toolbuttons for workflow sidebar (#3087) 2025-03-16 20:18:24 -04:00
samnyan
7ef6e52f38 [nit] add refresh button to workflow sidebar tab (#3062) 2025-03-16 19:46:13 -04:00
Comfy Org PR Bot
edeefe0883 [chore] Update litegraph to 0.10.8 (#3086)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-16 19:18:49 -04:00
MohammadAboulEla
c6046e47d2 Allowing control over domWidgets margin (#3085) 2025-03-16 19:16:11 -04:00
filtered
e8bcccc276 [Test] Update expectation - graph ID (#3083) 2025-03-17 05:29:16 +11:00
70 changed files with 929 additions and 1458 deletions

Binary file not shown.

View File

@@ -471,7 +471,6 @@ export class ComfyPage {
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -95,18 +95,6 @@ export class WorkflowsSidebarTab extends SidebarTab {
return this.page.locator('.workflows-sidebar-tab')
}
get browseGalleryButton() {
return this.root.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.root.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.root.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.root
.locator('.comfyui-workflows-open .node-label')

View File

@@ -673,7 +673,7 @@ test.describe('Load duplicate workflow', () => {
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.loadWorkflow('single_ksampler')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})

View File

@@ -8,8 +8,7 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm',
'workflow.glb'
'workflow.webm'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -119,7 +119,10 @@ test.describe('Menu', () => {
test('Can add new bookmark folder', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTab
await tab.newFolderButton.click()
await comfyPage.page.keyboard.press('Enter')
const textInput = comfyPage.page.locator('.editable-text input')
await textInput.waitFor({ state: 'visible' })
await textInput.fill('New Folder')
await textInput.press('Enter')
expect(await tab.getFolder('New Folder').count()).toBe(1)
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
@@ -132,8 +135,10 @@ test.describe('Menu', () => {
await tab.getFolder('foo').click({ button: 'right' })
await comfyPage.page.getByLabel('New Folder').click()
await comfyPage.page.keyboard.type('bar')
await comfyPage.page.keyboard.press('Enter')
const textInput = comfyPage.page.locator('.editable-text input')
await textInput.waitFor({ state: 'visible' })
await textInput.fill('bar')
await textInput.press('Enter')
expect(await tab.getFolder('bar').count()).toBe(1)
expect(
@@ -397,7 +402,7 @@ test.describe('Menu', () => {
'*Unsaved Workflow.json'
])
await tab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
@@ -496,7 +501,7 @@ test.describe('Menu', () => {
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
@@ -554,6 +559,8 @@ test.describe('Menu', () => {
})
// Compare the exported workflow with the original
delete downloadedContent.id
delete downloadedContentZh.id
expect(downloadedContent).toBeDefined()
expect(downloadedContent).toEqual(downloadedContentZh)
})
@@ -618,7 +625,7 @@ test.describe('Menu', () => {
// Load blank workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')

View File

@@ -57,13 +57,13 @@ test.describe('Templates', () => {
test('Can load template workflows', async ({ comfyPage }) => {
// Clear the workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
// Load a template
await comfyPage.menu.workflowsTab.browseGalleryButton.click()
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()

20
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.10",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.10",
"version": "1.14.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.10.7",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -460,15 +460,15 @@
}
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.4.31",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.31.tgz",
"integrity": "sha512-6tdUfrRyJ9mLlGhNxKqao0kdO+nKRLzQIbENmTK1EtJ1zhMmCp43a+pG7+kecjgp0pbfzxWKhTdCarS9A9fkqw==",
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.20.tgz",
"integrity": "sha512-JFKGk9wSx7CcYh9MRNo7bqTLJwQzVc+1Xg8V2Ghn9BS3RzpmkfktaWHi+waU7/CRQMzvjF+mnDPP58xk1xbVhA==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.7.tgz",
"integrity": "sha512-Cts4FBAk+e5RWPfmXUiBkjYzHkpy1eiW+DRCAYjGBGkobRhPZIBBHkk85vAnhMva704jSS9nsHG8c7r1/LUGyQ==",
"version": "0.10.9",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.10.9.tgz",
"integrity": "sha512-ubGozxdDIVNL/MYvfCAXgiaqBfIODtp0jZeN9uzWrdHwqUy9ZkLt/7/q7G4nGpNcEoShbMu7EK4VPH3WRmNQ7A==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.13.10",
"version": "1.14.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -72,8 +72,8 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.10.7",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.9",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -105,6 +105,5 @@
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39"
}
}

View File

@@ -4,7 +4,7 @@
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
:value="renderedRoots"
:value="renderedRoot.children"
selectionMode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
@@ -43,21 +43,24 @@ import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperations'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode
import {
InjectKeyExpandedKeys,
InjectKeyHandleEditLabelFunction,
type RenderedTreeExplorerNode,
type TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
provide('expandedKeys', expandedKeys)
provide(InjectKeyExpandedKeys, expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
provide('selectionKeys', selectionKeys)
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
roots: TreeExplorerNode[]
root: TreeExplorerNode
class?: string
}>()
const emit = defineEmits<{
@@ -65,8 +68,23 @@ const emit = defineEmits<{
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
}>()
const renderedRoots = computed<RenderedTreeExplorerNode[]>(() => {
return props.roots.map(fillNodeInfo)
const {
newFolderNode,
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(props.root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.getIcon) {
@@ -81,7 +99,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.leaf) {
return 'pi pi-file'
}
const isExpanded = expandedKeys.value[node.key]
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
@@ -95,7 +113,8 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : null
badgeText: node.getBadgeText ? node.getBadgeText() : null,
isEditingLabel: node.key === renameEditingNode.value?.key
}
}
const onNodeContentClick = async (
@@ -112,7 +131,6 @@ const onNodeContentClick = async (
}
const menu = ref(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
provide('menuTargetNode', menuTargetNode)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
@@ -121,7 +139,26 @@ const extraMenuItems = computed(() => {
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
provide('renameEditingNode', renameEditingNode)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
node: RenderedTreeExplorerNode,
newName: string
) => {
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
await handleFolderCreation(newName)
} else {
await node.handleRename(newName)
}
},
node.handleError,
() => {
renameEditingNode.value = null
}
)()
}
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
@@ -133,6 +170,7 @@ const deleteCommand = async (node: RenderedTreeExplorerNode) => {
}
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
@@ -163,7 +201,6 @@ const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
}
}
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
@@ -181,7 +218,14 @@ const wrapCommandWithErrorHandler = (
defineExpose({
renameCommand,
deleteCommand
deleteCommand,
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey))
}
})
</script>

View File

@@ -38,18 +38,17 @@
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { Ref, computed, inject, ref } from 'vue'
import { computed, inject, ref } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import type {
RenderedTreeExplorerNode,
TreeExplorerDragAndDropData,
TreeExplorerNode
import {
InjectKeyHandleEditLabelFunction,
type RenderedTreeExplorerNode,
type TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const props = defineProps<{
@@ -77,22 +76,12 @@ const nodeBadgeText = computed<string>(() => {
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const labelEditable = computed<boolean>(() => !!props.node.handleRename)
const renameEditingNode =
inject<Ref<TreeExplorerNode | null>>('renameEditingNode')
const isEditing = computed(
() => labelEditable.value && renameEditingNode.value?.key === props.node.key
)
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(newName)
},
props.node.handleError,
() => {
renameEditingNode.value = null
}
)
const isEditing = computed<boolean>(() => props.node.isEditingLabel)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel(props.node, newName)
}
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)

View File

@@ -1,6 +1,6 @@
<template>
<div ref="container" class="scroll-container">
<div :style="{ height: `${(state.start / cols) * itemSize}px` }" />
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<slot name="item" :item="item"> </slot>
@@ -8,7 +8,7 @@
</div>
<div
:style="{
height: `${((props.items.length - state.end) / cols) * itemSize}px`
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
</div>
@@ -19,22 +19,25 @@ import { useElementSize, useScroll } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{
const {
items,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200
} = defineProps<{
items: (T & { key: string })[]
gridStyle: Partial<CSSProperties>
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemSize?: number
defaultItemHeight?: number
defaultItemWidth?: number
}>()
const {
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemSize = 200
} = props
const itemSize = ref(defaultItemSize)
const itemHeight = ref(defaultItemHeight)
const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
@@ -42,12 +45,10 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() => Math.floor(width.value / itemSize.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemSize.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemSize.value))
const isValidGrid = computed(
() => height.value && width.value && props.items?.length
)
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
const state = computed<{ start: number; end: number }>(() => {
const fromRow = offsetRows.value - bufferRows
@@ -57,18 +58,19 @@ const state = computed<{ start: number; end: number }>(() => {
const toCol = toRow * cols.value
return {
start: clamp(fromCol, 0, props.items?.length),
end: clamp(toCol, fromCol, props.items?.length)
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length)
}
})
const renderedItems = computed(() =>
isValidGrid.value ? props.items.slice(state.value.start, state.value.end) : []
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
itemSize.value = firstItem?.clientHeight || defaultItemSize
itemHeight.value = firstItem?.clientHeight || defaultItemHeight
itemWidth.value = firstItem?.clientWidth || defaultItemWidth
}
}
const onResize = debounce(updateItemSize, resizeDebounce)

View File

@@ -10,8 +10,10 @@ import { createI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useToastStore } from '@/stores/toastStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import {
InjectKeyHandleEditLabelFunction,
RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
// Create a mock i18n instance
const i18n = createI18n({
@@ -47,7 +49,6 @@ describe('TreeExplorerTreeNode', () => {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
provide: { renameEditingNode: { value: null } },
plugins: [createTestingPinia(), i18n]
}
})
@@ -63,10 +64,14 @@ describe('TreeExplorerTreeNode', () => {
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
props: {
node: {
...mockNode,
isEditingLabel: true
}
},
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
@@ -75,62 +80,25 @@ describe('TreeExplorerTreeNode', () => {
expect(editableText.props('isEditing')).toBe(true)
})
it('triggers handleRename callback when editing is finished', async () => {
const handleRenameMock = vi.fn()
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
it('triggers handleEditLabel callback when editing is finished', async () => {
const handleEditLabelMock = vi.fn()
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
props: {
node: {
...mockNode,
isEditingLabel: true
}
},
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
provide: { [InjectKeyHandleEditLabelFunction]: handleEditLabelMock },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
expect(handleRenameMock).toHaveBeenCalledOnce()
})
it('shows error toast when handleRename promise rejects', async () => {
const handleRenameMock = vi
.fn()
.mockRejectedValue(new Error('Rename failed'))
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const toastStore = useToastStore()
const addToastSpy = vi.spyOn(toastStore, 'add')
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Wait for the promise to reject and the toast to be added
vi.runAllTimers()
// Wait for any pending promises to resolve
await new Promise(process.nextTick)
expect(handleRenameMock).toHaveBeenCalledOnce()
expect(addToastSpy).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Rename failed'
})
expect(handleEditLabelMock).toHaveBeenCalledOnce()
})
})

View File

@@ -25,6 +25,10 @@
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
<template #footer v-if="item.footerComponent">
<component :is="item.footerComponent" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,114 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'max-h-0 p-0 m-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="px-6 py-4 overflow-y-auto max-h-[450px] scroll-container"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
:class="{
'max-h-[450px]': isExpanded,
'max-h-0': !isExpanded
}"
>
<div v-for="(panel, index) in taskPanels" :key="index">
<Panel
:expanded="expandedPanels[index] || false"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
<template #header>
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span v-show="expandedPanels[index]" class="text-muted">
{{
index === taskPanels.length - 1
? 'In progress'
: 'Completed '
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
:icon="
expandedPanels[index]
? 'pi pi-chevron-down'
: 'pi pi-chevron-right'
"
text
class="text-neutral-300"
@click="togglePanel(index)"
/>
</template>
<div
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
>
<div class="h-full">
<div
v-for="(log, logIndex) in panel.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
>
<pre class="whitespace-pre-wrap break-words">{{ log }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const expandedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
expandedPanels.value[index] = !expandedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef)
const scrollToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
whenever(() => isExpanded.value, scrollToBottom)
onMounted(() => {
expandedPanels.value = {}
scrollToBottom()
})
onBeforeUnmount(() => {
progressDialogContent.collapse()
})
</script>

View File

@@ -124,7 +124,7 @@ import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_CARD_SIZE = 512
const DEFAULT_CARD_SIZE = 349
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -25,7 +25,7 @@
option-value="value"
:options="allVersionOptions"
:highlight-on-select="false"
class="my-3 w-full max-h-[50vh] border-none"
class="my-3 w-full max-h-[50vh] border-none shadow-none"
>
<template #option="slotProps">
<div class="flex justify-between items-center w-full p-1">

View File

@@ -5,62 +5,65 @@
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
}"
:pt="{
body: { class: 'p-0 flex flex-col h-full rounded-2xl' },
body: { class: 'p-0 flex flex-col h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: { class: 'p-0 m-0' },
title: {
class:
'self-stretch px-4 py-3 inline-flex justify-start items-center gap-6'
},
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<div class="flex justify-between p-5 pb-1 align-middle text-sm">
<span class="flex items-start mt-2">
<i
class="pi pi-box text-muted text-2xl ml-1 mr-5"
style="opacity: 0.5"
/>
<span class="text-lg relative top-[.25rem]">{{
$t('manager.nodePack')
}}</span>
</span>
<div class="flex items-center gap-2.5">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isPackInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
<PackCardHeader :node-pack="nodePack" />
</template>
<template #content>
<ContentDivider />
<div class="flex flex-1 p-5 mt-3 cursor-pointer">
<div class="flex-shrink-0 mr-4">
<PackIcon :node-pack="nodePack" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<span
class="text-lg font-bold pb-4 truncate overflow-hidden text-ellipsis"
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<div class="flex-1">
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<p
v-if="nodePack.description"
class="text-sm text-color-secondary m-0 line-clamp-3 overflow-hidden"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
</div>
</template>
@@ -76,21 +79,35 @@ import Card from 'primevue/card'
import { computed } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
const { nodePack, isSelected = false } = defineProps<{
nodePack: components['schemas']['Node']
isSelected?: boolean
}>()
const managerStore = useComfyManagerStore()
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isPackInstalled = computed(() =>
managerStore.isPackInstalled(nodePack?.id)
)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isUpdateAvailable = computed(() => {
if (!isInstalled.value) return false
const latestVersion = nodePack.latest_version?.version
if (!latestVersion) return false
const installedVersion = getInstalledPackVersion(nodePack.id)
// Don't attempt to show update available for nightly GitHub packs
if (installedVersion && !isSemVer(installedVersion)) return false
return compareVersions(latestVersion, installedVersion) > 0
})
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
</script>

View File

@@ -1,10 +1,11 @@
<template>
<div class="flex justify-between p-5 text-xs text-muted">
<div
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
>
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="nodePack.publisher?.name">
{{ nodePack.publisher.name }}
<span v-if="publisherName" class="max-w-40 truncate">
{{ publisherName }}
</span>
<PackVersionBadge v-if="isInstalled" :node-pack="nodePack" />
<span v-else-if="nodePack.latest_version">
{{ nodePack.latest_version.version }}
</span>
@@ -26,14 +27,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="w-[100%] flex justify-between items-center">
<div class="flex justify-start items-center">
<div class="w-1 h-6 rounded-md" />
<div class="w-6 h-6 relative overflow-hidden">
<i class="pi pi-box text-xl text-muted" style="opacity: 0.6" />
</div>
<div class="px-3 py-2 rounded-md flex justify-start items-start gap-2.5">
<div class="text-right justify-start text-sm font-bold leading-none">
{{ $t('manager.nodePack') }}
</div>
</div>
</div>
<div class="inline-flex justify-start items-center gap-3">
<div
v-if="nodePack.downloads"
class="flex items-center text-sm text-muted tracking-tighter"
>
<i class="pi pi-download mr-2" />
{{ $n(nodePack.downloads) }}
</div>
<template v-if="isInstalled">
<PackEnableToggle :node-pack="nodePack" />
</template>
<template v-else>
<PackInstallButton :node-packs="[nodePack]" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PackEnableToggle from '@/components/dialog/content/manager/button/PackEnableToggle.vue'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { isPackInstalled } = useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="w-full px-6 py-4 shadow-lg flex items-center justify-between"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="justify-center text-sm font-bold leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<i class="pi pi-spin pi-spinner mr-2 text-3xl" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<i class="pi pi-check-circle mr-2 text-green-500" />
<span class="leading-none">{{
$t('manager.restartToApplyChanges')
}}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-xs font-bold text-neutral-600">
{{ comfyManagerStore.uncompletedCount }} of
{{ comfyManagerStore.taskLogs.length }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress"
rounded
outlined
class="px-4 py-2 rounded-md mr-4"
@click="handleRestart"
>
{{ $t('g.restart') }}
</Button>
<Button
:icon="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-right'
"
text
rounded
size="small"
severity="secondary"
:aria-label="progressDialogContent.isExpanded ? 'Collapse' : 'Expand'"
@click.stop="progressDialogContent.toggle"
/>
<Button
icon="pi pi-times"
text
rounded
size="small"
severity="secondary"
aria-label="Close"
@click.stop="closeDialog"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { useComfyManagerService } from '@/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const isInProgress = computed(() => comfyManagerStore.uncompletedCount > 0)
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('g.installing')
const currentTaskName = computed(() => {
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
await useComfyManagerService().rebootComfyUI()
closeDialog()
const onReconnect = () => {
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="px-4 py-2 flex items-center"
>
<TabMenu
v-model:activeIndex="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
</template>
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { ref } from 'vue'
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const activeTabIndex = ref(0)
const tabs = [{ label: 'Installation Queue' }, { label: 'Failed (0)' }]
</script>

View File

@@ -30,7 +30,7 @@ const widgets = computed(() =>
)
)
const MARGIN = 10
const DEFAULT_MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
@@ -49,10 +49,11 @@ const updateWidgets = () => {
widgetState.visible = visible
if (visible) {
widgetState.pos = [node.pos[0] + MARGIN, node.pos[1] + MARGIN + widget.y]
const margin = widget.options.margin ?? DEFAULT_MARGIN
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [
(widget.width ?? node.width) - MARGIN * 2,
(widget.computedHeight ?? 50) - MARGIN * 2
(widget.width ?? node.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)

View File

@@ -33,9 +33,6 @@
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
@@ -83,7 +80,6 @@ const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
@@ -104,7 +100,6 @@ const validatePath = async (path: string) => {
try {
pathError.value = ''
pathExists.value = false
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path)
// Create a pre-formatted list of errors
@@ -116,14 +111,12 @@ const validatePath = async (path: string) => {
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
// Display the path exists warning
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')

View File

@@ -32,7 +32,7 @@
<TreeExplorer
class="model-lib-tree-explorer"
:roots="renderedRoot.children"
:root="renderedRoot"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">

View File

@@ -49,7 +49,7 @@
/>
<TreeExplorer
class="node-lib-tree-explorer"
:roots="renderedRoot.children"
:root="renderedRoot"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">

View File

@@ -5,28 +5,18 @@
>
<template #tool-buttons>
<Button
class="browse-templates-button"
icon="pi pi-th-large"
icon="pi pi-folder-plus"
@click="addNewFolder"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.browseTemplates')"
text
@click="() => commandStore.execute('Comfy.BrowseTemplates')"
v-tooltip.bottom="$t('g.newFolder')"
/>
<Button
class="open-workflow-button"
icon="pi pi-folder-open"
icon="pi pi-refresh"
@click="workflowStore.syncWorkflows()"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.openWorkflow')"
text
@click="() => commandStore.execute('Comfy.OpenWorkflow')"
/>
<Button
class="new-blank-workflow-button"
icon="pi pi-plus"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
text
v-tooltip.bottom="$t('g.refresh')"
/>
</template>
<template #header>
@@ -49,9 +39,7 @@
class="ml-2"
/>
<TreeExplorer
:roots="
renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open).children
"
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selectionKeys="selectionKeys"
>
<template #node="{ node }">
@@ -87,11 +75,11 @@
class="ml-2"
/>
<TreeExplorer
:roots="
:root="
renderTreeNode(
bookmarkedWorkflowsTree,
WorkflowTreeType.Bookmarks
).children
)
"
:selectionKeys="selectionKeys"
>
@@ -107,11 +95,10 @@
class="ml-2"
/>
<TreeExplorer
:roots="
renderTreeNode(workflowsTree, WorkflowTreeType.Browse).children
"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
v-model:expandedKeys="expandedKeys"
:selectionKeys="selectionKeys"
ref="persistedWorkflowsTreeExplorerRef"
v-if="workflowStore.persistedWorkflows.length > 0"
>
<template #node="{ node }">
@@ -128,9 +115,7 @@
</div>
<div class="comfyui-workflows-search-panel" v-else>
<TreeExplorer
:roots="
renderTreeNode(filteredRoot, WorkflowTreeType.Browse).children
"
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
v-model:expandedKeys="expandedKeys"
>
<template #node="{ node }">
@@ -159,7 +144,6 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
import {
useWorkflowBookmarkStore,
@@ -197,7 +181,6 @@ const handleSearch = (query: string) => {
})
}
const commandStore = useCommandStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workspaceStore = useWorkspaceStore()
@@ -244,7 +227,6 @@ const renderTreeNode = (
type: WorkflowTreeType
): TreeExplorerNode<ComfyWorkflow> => {
const children = node.children?.map((child) => renderTreeNode(child, type))
const workflow: ComfyWorkflow = node.data
function handleClick(this: TreeExplorerNode<ComfyWorkflow>, e: MouseEvent) {
@@ -255,7 +237,7 @@ const renderTreeNode = (
}
}
const actions = node.leaf
const actions: Partial<TreeExplorerNode<ComfyWorkflow>> = node.leaf
? {
handleClick,
async handleRename(newName: string) {
@@ -285,7 +267,16 @@ const renderTreeNode = (
},
draggable: true
}
: { handleClick }
: {
handleClick,
async handleAddFolder(folderName: string) {
if (folderName === '') return
const parentPath = this.key.replace(/^root\/?/, '')
const folderPath = parentPath + '/' + folderName
await workflowStore.createFolder(folderPath)
}
}
return {
key: node.key,
@@ -305,4 +296,11 @@ const workflowBookmarkStore = useWorkflowBookmarkStore()
onMounted(async () => {
await workflowBookmarkStore.loadBookmarks()
})
const persistedWorkflowsTreeExplorerRef = ref<InstanceType<
typeof TreeExplorer
> | null>(null)
const addNewFolder = () => {
persistedWorkflowsTreeExplorerRef.value?.addFolderCommand?.('root')
}
</script>

View File

@@ -2,7 +2,7 @@
<TreeExplorer
class="node-lib-bookmark-tree-explorer"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:root="renderedBookmarkedRoot"
:expandedKeys="expandedKeys"
>
<template #folder="{ node }">
@@ -40,7 +40,6 @@ import type {
TreeExplorerDragAndDropData,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { findNodeByKey } from '@/utils/treeUtil'
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
@@ -94,14 +93,6 @@ const { t } = useI18n()
const extraMenuItems = (
menuTargetNode: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) => [
{
label: t('g.newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
addNewBookmarkFolder(menuTargetNode)
},
visible: !menuTargetNode?.leaf
},
{
label: t('g.customize'),
icon: 'pi pi-palette',
@@ -152,6 +143,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
},
children: sortedChildren,
draggable: node.leaf,
handleAddFolder(newName: string) {
if (newName !== '') {
nodeBookmarkStore.addNewBookmarkFolder(this.data, newName)
}
},
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
render(vnode, container)
@@ -197,25 +193,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
)
const treeExplorerRef = ref<InstanceType<typeof TreeExplorer> | null>(null)
const addNewBookmarkFolder = (
parent?: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) => {
const newFolderKey =
'root/' + nodeBookmarkStore.addNewBookmarkFolder(parent?.data).slice(0, -1)
nextTick(() => {
treeExplorerRef.value?.renameCommand(
findNodeByKey(
renderedBookmarkedRoot.value,
newFolderKey
) as RenderedTreeExplorerNode
)
if (parent) {
expandedKeys.value[parent.key] = true
}
})
}
defineExpose({
addNewBookmarkFolder
addNewBookmarkFolder: () => treeExplorerRef.value?.addFolderCommand('root')
})
const showCustomizationDialog = ref(false)

View File

@@ -8,21 +8,27 @@
</template>
<script setup lang="ts">
import { Ref, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import type { BookmarkCustomization } from '@/schemas/apiSchema'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import {
InjectKeyExpandedKeys,
type RenderedTreeExplorerNode
} from '@/types/treeExplorerTypes'
const props = defineProps<{
const { node } = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
}>()
const nodeBookmarkStore = useNodeBookmarkStore()
const customization = computed<BookmarkCustomization | undefined>(() => {
return nodeBookmarkStore.bookmarksCustomization[props.node.data.nodePath]
const nodeDef = node.data
return nodeDef
? nodeBookmarkStore.bookmarksCustomization[nodeDef.nodePath]
: undefined
})
const treeNodeElement = ref<HTMLElement | null>(null)
@@ -56,7 +62,7 @@ onUnmounted(() => {
}
})
const expandedKeys = inject<Ref<Record<string, boolean>>>('expandedKeys')
const expandedKeys = inject(InjectKeyExpandedKeys)
const handleItemDrop = (node: RenderedTreeExplorerNode) => {
expandedKeys.value[node.key] = true
}

View File

@@ -0,0 +1,76 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
/**
* Use this to handle folder operations in a tree.
* @param expandNode - The function to expand a node.
*/
export function useTreeFolderOperations(
expandNode: (node: RenderedTreeExplorerNode) => void
) {
const { t } = useI18n()
const newFolderNode = ref<RenderedTreeExplorerNode | null>(null)
const addFolderTargetNode = ref<RenderedTreeExplorerNode | null>(null)
// Generate a unique temporary key for the new folder
const generateTempKey = (parentKey: string) => {
return `${parentKey}/new_folder_${Date.now()}`
}
// Handle folder creation after name is confirmed
const handleFolderCreation = async (newName: string) => {
if (!newFolderNode.value || !addFolderTargetNode.value) return
try {
// Call the handleAddFolder method with the new folder name
await addFolderTargetNode.value?.handleAddFolder?.(newName)
} finally {
newFolderNode.value = null
addFolderTargetNode.value = null
}
}
/**
* The command to add a folder to a node via the context menu
* @param targetNode - The node where the folder will be added under
*/
const addFolderCommand = (targetNode: RenderedTreeExplorerNode) => {
expandNode(targetNode)
newFolderNode.value = {
key: generateTempKey(targetNode.key),
label: '',
leaf: false,
children: [],
icon: 'pi pi-folder',
type: 'folder',
totalLeaves: 0,
badgeText: '',
isEditingLabel: true
}
addFolderTargetNode.value = targetNode
}
// Generate the "Add Folder" menu item
const getAddFolderMenuItem = (
targetNode: RenderedTreeExplorerNode | null
) => {
return {
label: t('g.newFolder'),
icon: 'pi pi-folder-plus',
command: () => {
if (targetNode) addFolderCommand(targetNode)
},
visible: targetNode && !targetNode.leaf && !!targetNode.handleAddFolder,
isAsync: false
}
}
return {
newFolderNode,
addFolderCommand,
getAddFolderMenuItem,
handleFolderCreation
}
}

View File

@@ -590,6 +590,15 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
dialogService.showManagerDialog()
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle Progress Dialog',
versionAdded: '1.13.9',
function: () => {
dialogService.showManagerProgressDialog()
}
}
]
}

View File

@@ -10,7 +10,7 @@ import {
import type { NodesIndexSuggestion } from '@/services/algoliaSearchService'
import { PackField } from '@/types/comfyManagerTypes'
const SEARCH_DEBOUNCE_TIME = 16
const SEARCH_DEBOUNCE_TIME = 256
const DEFAULT_PAGE_SIZE = 64
/**

View File

@@ -23,30 +23,20 @@ export function useTreeExpansion(expandedKeys: Ref<Record<string, boolean>>) {
}
const expandNode = (node: TreeNode) => {
if (
node.key &&
typeof node.key === 'string' &&
node.children &&
node.children.length
) {
if (node.key && typeof node.key === 'string' && !node.leaf) {
expandedKeys.value[node.key] = true
for (const child of node.children) {
for (const child of node.children ?? []) {
expandNode(child)
}
}
}
const collapseNode = (node: TreeNode) => {
if (
node.key &&
typeof node.key === 'string' &&
node.children &&
node.children.length
) {
if (node.key && typeof node.key === 'string' && !node.leaf) {
delete expandedKeys.value[node.key]
for (const child of node.children) {
for (const child of node.children ?? []) {
collapseNode(child)
}
}

View File

@@ -95,7 +95,6 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image

View File

@@ -12,7 +12,6 @@ import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'
import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'

View File

@@ -8,11 +8,6 @@ import { api } from '@/scripts/api'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
this.setupDefaultProperties()
}
configure(
loadFolder: 'input' | 'output',
modelWidget: IWidget,
@@ -39,17 +34,6 @@ class Load3DConfiguration {
}
}
private setupModelHandlingForSaveMesh(
filePath: string,
loadFolder: 'input' | 'output'
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
if (filePath) {
onModelWidgetUpdate(filePath)
}
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',

View File

@@ -1,79 +0,0 @@
import { IWidget } from '@comfyorg/litegraph'
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.SaveGLB',
async beforeRegisterNodeDef(_nodeType, nodeData) {
if ('SaveGLB' === nodeData.name) {
// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.image = ['PREVIEW_3D']
}
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D'
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,
inputSpec,
options: {}
})
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'SaveGLB') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const fileInfo = message['3d'][0]
const load3d = useLoad3dService().getLoad3d(node)
const modelWidget = node.widgets?.find((w: IWidget) => w.name === 'image')
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
console.log(filePath)
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d)
config.configureForSaveMesh(fileInfo['type'], filePath)
}
}
}
})

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Custom Nodes Manager"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle Progress Dialog"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -94,10 +94,12 @@
"filter": "Filter",
"apply": "Apply",
"enabled": "Enabled",
"installed": "Installed"
"installed": "Installed",
"restart": "Restart"
},
"manager": {
"title": "Custom Nodes Manager",
"restartToApplyChanges": "To apply changes, please restart ComfyUI",
"loadingVersions": "Loading versions...",
"selectVersion": "Select Version",
"downloads": "Downloads",
@@ -258,11 +260,9 @@
"pathExists": "Directory already exists - please ensure you have backed up all data",
"cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
"parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error",
"installLocationDescription": "Select the directory for ComfyUI's user data. A python environment will be installed to the selected location.",
"installLocationDescription": "Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
"installLocationTooltip": "ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
"appDataLocationTooltip": "ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
"appPathLocationTooltip": "ComfyUI's app asset directory. Stores the ComfyUI code and assets",
@@ -508,10 +508,7 @@
"area_composition_square_area_for_subject": "Area Composition Square Area for Subject"
},
"3D": {
"stable_zero123_example": "Stable Zero123",
"hunyuan3d-non-multiview-train": "Hunyuan3D 2.0",
"hunyuan-3d-multiview-elf": "Hunyuan3D 2.0 MV",
"hunyuan-3d-turbo": "Hunyuan3D 2.0 MV Turbo"
"stable_zero123_example": "Stable Zero123"
},
"Audio": {
"stable_audio_example": "Stable Audio"
@@ -620,6 +617,7 @@
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",
@@ -851,7 +849,6 @@
"latent": "latent",
"video": "video",
"audio": "audio",
"3d": "3d",
"ltxv": "ltxv",
"sd3": "sd3",
"sigmas": "sigmas",
@@ -867,6 +864,7 @@
"compositing": "compositing",
"samplers": "samplers",
"operations": "operations",
"3d": "3d",
"debug": "debug",
"model": "model",
"model_specific": "model_specific",
@@ -903,7 +901,6 @@
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"MASK": "MASK",
"MESH": "MESH",
"MODEL": "MODEL",
"NOISE": "NOISE",
"PHOTOMAKER": "PHOTOMAKER",
@@ -914,7 +911,6 @@
"TIMESTEPS_RANGE": "TIMESTEPS_RANGE",
"UPSCALE_MODEL": "UPSCALE_MODEL",
"VAE": "VAE",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
"maintenance": {

View File

@@ -1234,18 +1234,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"resolution": {
"name": "resolution"
},
"batch_size": {
"name": "batch_size",
"tooltip": "The number of latent images in the batch."
}
}
},
"EmptyLatentImage": {
"display_name": "Empty Latent Image",
"description": "Create a new batch of empty latent images to be denoised via sampling.",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive"
},
"1": {
"name": "negative"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"front": {
"name": "front"
},
"left": {
"name": "left"
},
"back": {
"name": "back"
},
"right": {
"name": "right"
}
},
"outputs": {
"0": {
"name": "positive"
},
"1": {
"name": "negative"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2417,9 +2364,6 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "choose file to upload"
}
@@ -4665,9 +4609,6 @@
"inputs": {
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5025,23 +4966,6 @@
},
"filename_prefix": {
"name": "filename_prefix"
},
"audioUI": {
"name": "audioUI"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"mesh": {
"name": "mesh"
},
"filename_prefix": {
"name": "filename_prefix"
},
"image": {
"name": "image"
}
}
},
@@ -5801,23 +5725,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"samples": {
"name": "samples"
},
"vae": {
"name": "vae"
},
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "octree_resolution"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE Decode (Tiled)",
"inputs": {
@@ -5948,17 +5855,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"voxel": {
"name": "voxel"
},
"threshold": {
"name": "threshold"
}
}
},
"VPScheduler": {
"display_name": "VPScheduler",
"inputs": {

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Gestionnaire de Nœuds Personnalisés"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -74,7 +74,6 @@
"LOAD_3D": "CHARGER_3D",
"LOAD_3D_ANIMATION": "CHARGER_ANIMATION_3D",
"MASK": "MASQUE",
"MESH": "MAILLAGE",
"MODEL": "MODÈLE",
"NOISE": "BRUIT",
"PHOTOMAKER": "PHOTOMAKER",
@@ -85,7 +84,6 @@
"TIMESTEPS_RANGE": "PLAGE_DES_ÉTAPES_TEMPORELLES",
"UPSCALE_MODEL": "MODÈLE_DE_MISE_À_L'ÉCHELLE",
"VAE": "VAE",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
"desktopMenu": {
@@ -190,6 +188,7 @@
"reportSent": "Rapport soumis",
"reset": "Réinitialiser",
"resetKeybindingsTooltip": "Réinitialiser les raccourcis clavier par défaut",
"restart": "Redémarrer",
"resultsCount": "{count} Résultats Trouvés",
"save": "Enregistrer",
"saving": "Enregistrement",
@@ -268,7 +267,6 @@
"installLocationDescription": "Sélectionnez le répertoire pour les données utilisateur de ComfyUI. Un environnement python sera installé à l'emplacement sélectionné. Veuillez vous assurer que le disque sélectionné a suffisamment d'espace (~15GB) restant.",
"installLocationTooltip": "Répertoire des données utilisateur de ComfyUI. Stocke :\n- Environnement Python\n- Modèles\n- Nœuds personnalisés\n",
"insufficientFreeSpace": "Espace insuffisant - espace libre minimum",
"isOneDrive": "L'installation dans OneDrive peut causer des problèmes. Nous recommandons fortement d'installer dans un emplacement non-OneDrive.",
"manualConfiguration": {
"createVenv": "Vous devrez créer un environnement virtuel dans le répertoire suivant",
"requirements": "Exigences",
@@ -283,7 +281,6 @@
"migrationOptional": "La migration est facultative. Si vous n'avez pas d'installation existante, vous pouvez sauter cette étape.",
"migrationSourcePathDescription": "Si vous avez une installation existante de ComfyUI, nous pouvons copier/lier vos fichiers utilisateur et modèles existants à la nouvelle installation. Votre installation existante de ComfyUI ne sera pas affectée.",
"moreInfo": "Pour plus d'informations, veuillez lire notre",
"nonDefaultDrive": "Veuillez installer ComfyUI sur le disque système de votre ordinateur (par exemple C:\\). Les disques avec des systèmes de fichiers différents peuvent causer des problèmes imprévisibles. Les modèles et autres fichiers peuvent être stockés sur d'autres disques après l'installation.",
"parentMissing": "Le chemin n'existe pas - créez d'abord le répertoire contenant",
"pathExists": "Le répertoire existe déjà - veuillez vous assurer que vous avez sauvegardé toutes les données",
"pathValidationFailed": "Échec de la validation du chemin",
@@ -401,6 +398,7 @@
"nodePack": "Pack de Nœuds",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
"restartToApplyChanges": "Pour appliquer les modifications, veuillez redémarrer ComfyUI",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"sort": {
@@ -534,6 +532,7 @@
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
"Toggle Progress Dialog": "Basculer la boîte de dialogue de progression",
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
"Toggle Search Box": "Basculer la boîte de recherche",
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
@@ -884,9 +883,6 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Multivue",
"hunyuan-3d-turbo": "Hunyuan3D Turbo",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,18 +1251,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "taille_du_lot",
"tooltip": "Le nombre d'images latentes dans le lot."
},
"resolution": {
"name": "résolution"
}
}
},
"EmptyLatentImage": {
"description": "Créez un nouveau lot d'images latentes vides à débruiter via l'échantillonnage.",
"display_name": "Image Latente Vide",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "sortie_vision_clip"
}
},
"outputs": {
"0": {
"name": "positif"
},
"1": {
"name": "négatif"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "arrière"
},
"front": {
"name": "avant"
},
"left": {
"name": "gauche"
},
"right": {
"name": "droite"
}
},
"outputs": {
"0": {
"name": "positif"
},
"1": {
"name": "négatif"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2589,9 +2536,6 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "choisissez le fichier à télécharger"
}
@@ -4670,9 +4614,6 @@
"inputs": {
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5153,28 +5094,11 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "préfixe_du_nom_de_fichier"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "préfixe_du_nom_de_fichier"
},
"image": {
"name": "image"
},
"mesh": {
"name": "maillage"
}
}
},
"SaveImage": {
"description": "Enregistre les images d'entrée dans votre répertoire de sortie ComfyUI.",
"display_name": "Enregistrer Image",
@@ -5781,23 +5705,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "nombre_de_morceaux"
},
"octree_resolution": {
"name": "résolution_octree"
},
"samples": {
"name": "échantillons"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE Decode (Tiled)",
"inputs": {
@@ -5945,17 +5852,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "seuil"
},
"voxel": {
"name": "voxel"
}
}
},
"WanImageToVideo": {
"display_name": "WanImageVersVidéo",
"inputs": {

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "カスタムノードマネージャ"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -74,7 +74,6 @@
"LOAD_3D": "3Dをロード",
"LOAD_3D_ANIMATION": "3Dアニメーションをロード",
"MASK": "マスク",
"MESH": "メッシュ",
"MODEL": "モデル",
"NOISE": "ノイズ",
"PHOTOMAKER": "PHOTOMAKER",
@@ -85,7 +84,6 @@
"TIMESTEPS_RANGE": "タイムステップの範囲",
"UPSCALE_MODEL": "アップスケールモデル",
"VAE": "VAE",
"VOXEL": "ボクセル",
"WEBCAM": "ウェブカメラ"
},
"desktopMenu": {
@@ -190,6 +188,7 @@
"reportSent": "レポートが送信されました",
"reset": "リセット",
"resetKeybindingsTooltip": "キーバインディングをデフォルトにリセット",
"restart": "再起動",
"resultsCount": "{count}件の結果が見つかりました",
"save": "保存",
"saving": "保存中",
@@ -268,7 +267,6 @@
"installLocationDescription": "ComfyUIのユーザーデータを保存するディレクトリを選択してください。Python環境が選択した場所にインストールされます。選択したディスクに約15GBの空き容量が必要です。",
"installLocationTooltip": "ComfyUIのユーザーデータディレクトリ。保存内容:\n- Python環境\n- モデル\n- カスタムノード\n",
"insufficientFreeSpace": "空き容量が不足しています - 最低限の空き容量",
"isOneDrive": "OneDriveにインストールすると問題が発生する可能性があります。非OneDriveの場所にインストールすることを強くお勧めします。",
"manualConfiguration": {
"createVenv": "次のディレクトリに仮想環境を作成する必要があります",
"requirements": "要件",
@@ -283,7 +281,6 @@
"migrationOptional": "移行は任意です。既存のインストールがない場合、このステップをスキップできます。",
"migrationSourcePathDescription": "既存のComfyUIインストールがある場合、既存のユーザーファイルとモデルを新しいインストールにコピー/リンクすることができます。既存のComfyUIインストールは影響を受けません。",
"moreInfo": "詳しくはこちらをご覧ください",
"nonDefaultDrive": "ComfyUIをシステムドライブC:\\)にインストールしてください。異なるファイルシステムを持つドライブでは、予測不能な問題が発生する可能性があります。インストール後にモデルやその他のファイルを他のドライブに保存することができます。",
"parentMissing": "パスが存在しません - 最初に含まれるディレクトリを作成してください",
"pathExists": "ディレクトリはすでに存在します - すべてのデータをバックアップしたことを確認してください",
"pathValidationFailed": "パスの検証に失敗しました",
@@ -401,6 +398,7 @@
"nodePack": "ノードパック",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
"restartToApplyChanges": "変更を適用するには、ComfyUIを再起動してください",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"sort": {
@@ -534,6 +532,7 @@
"Toggle Logs Bottom Panel": "ログパネル下部を切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Progress Dialog": "進行状況ダイアログの切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナルパネル下部を切り替え",
@@ -884,9 +883,6 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D マルチビュー",
"hunyuan-3d-turbo": "Hunyuan3D ターボ",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,18 +1251,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "バッチサイズ",
"tooltip": "バッチ内の潜在画像の数。"
},
"resolution": {
"name": "解像度"
}
}
},
"EmptyLatentImage": {
"description": "サンプリングを通じてノイズを除去するための空の潜在画像の新しいバッチを作成します。",
"display_name": "空の潜在画像",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "ポジティブ"
},
"1": {
"name": "ネガティブ"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "バック"
},
"front": {
"name": "フロント"
},
"left": {
"name": "左"
},
"right": {
"name": "右"
}
},
"outputs": {
"0": {
"name": "ポジティブ"
},
"1": {
"name": "ネガティブ"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2589,9 +2536,6 @@
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "アップロードするファイルを選択"
}
@@ -4670,9 +4614,6 @@
"inputs": {
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5153,28 +5094,11 @@
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "ファイル名_プレフィックス"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "ファイル名のプレフィックス"
},
"image": {
"name": "画像"
},
"mesh": {
"name": "メッシュ"
}
}
},
"SaveImage": {
"description": "入力画像をComfyUI出力ディレクトリに保存します。",
"display_name": "画像を保存",
@@ -5781,23 +5705,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "octree_resolution"
},
"samples": {
"name": "サンプル"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAEデコードタイル",
"inputs": {
@@ -5945,17 +5852,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "閾値"
},
"voxel": {
"name": "ボクセル"
}
}
},
"WanImageToVideo": {
"display_name": "Wan画像からビデオへ",
"inputs": {

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "사용자 정의 노드 관리자"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "프로그레스 대화 상자 전환"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -74,7 +74,6 @@
"LOAD_3D": "3D 로드",
"LOAD_3D_ANIMATION": "3D 애니메이션 로드",
"MASK": "마스크",
"MESH": "메시",
"MODEL": "모델",
"NOISE": "노이즈",
"PHOTOMAKER": "PHOTOMAKER",
@@ -85,7 +84,6 @@
"TIMESTEPS_RANGE": "타임스텝 범위",
"UPSCALE_MODEL": "업스케일 모델",
"VAE": "VAE",
"VOXEL": "복셀",
"WEBCAM": "웹캠"
},
"desktopMenu": {
@@ -190,6 +188,7 @@
"reportSent": "보고서 제출됨",
"reset": "재설정",
"resetKeybindingsTooltip": "키 바인딩을 기본값으로 재설정",
"restart": "재시작",
"resultsCount": "{count} 개의 결과를 찾았습니다",
"save": "저장",
"saving": "저장 중",
@@ -268,7 +267,6 @@
"installLocationDescription": "ComfyUI의 사용자 데이터 디렉토리를 선택하십시오. 선택한 위치에 Python 환경이 설치됩니다. 선택한 디스크에 충분한 공간(~15GB)이 남아 있는지 확인하십시오.",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 사용자 정의 노드\n",
"insufficientFreeSpace": "공간이 부족합니다 - 최소한의 여유 공간",
"isOneDrive": "OneDrive에 설치하면 문제가 발생할 수 있습니다. OneDrive가 아닌 위치에 설치하는 것을 강력히 권장합니다.",
"manualConfiguration": {
"createVenv": "다음 디렉토리에 가상 환경을 생성해야 합니다",
"requirements": "요구 사항",
@@ -283,7 +281,6 @@
"migrationOptional": "마이그레이션은 선택 사항입니다. 기존에 설치된 것이 없다면, 이 단계를 건너뛸 수 있습니다.",
"migrationSourcePathDescription": "기존에 설치된 ComfyUI가 있으면, 기존 사용자 파일과 모델을 새 설치본으로 복사하거나 링크 할 수 있습니다. 기존의 ComfyUI 설치는 영향을 받지 않습니다.",
"moreInfo": "더 많은 정보를 원하시면, 다음을 읽어주세요",
"nonDefaultDrive": "ComfyUI를 시스템 드라이브(예: C:\\)에 설치하십시오. 다른 파일 시스템을 가진 드라이브는 예측할 수 없는 문제를 일으킬 수 있습니다. 설치 후에는 모델 및 기타 파일을 다른 드라이브에 저장할 수 있습니다.",
"parentMissing": "경로가 존재하지 않습니다 - 먼저 포함하는 디렉토리를 생성하세요",
"pathExists": "디렉토리가 이미 존재합니다 - 모든 데이터를 백업했는지 확인해 주세요",
"pathValidationFailed": "경로 유효성 검사 실패",
@@ -401,6 +398,7 @@
"nodePack": "노드 팩",
"packsSelected": "선택한 팩",
"repository": "저장소",
"restartToApplyChanges": "변경 사항을 적용하려면 ComfyUI를 재시작해 주세요",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"sort": {
@@ -534,6 +532,7 @@
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Progress Dialog": "진행 상황 대화 상자 전환",
"Toggle Queue Sidebar": "실행 큐 사이드바 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
@@ -884,9 +883,6 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D 다중뷰",
"hunyuan-3d-turbo": "Hunyuan3D 터보",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "스테이블 제로123"
},
"Area Composition": {

View File

@@ -1251,18 +1251,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "배치 크기",
"tooltip": "배치에 있는 잠재 이미지의 수입니다."
},
"resolution": {
"name": "해상도"
}
}
},
"EmptyLatentImage": {
"description": "샘플링을 통해 디노이즈할 빈 잠재 이미지의 새 배치를 생성합니다.",
"display_name": "빈 잠재 이미지",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "긍정적"
},
"1": {
"name": "부정적"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "뒤"
},
"front": {
"name": "앞"
},
"left": {
"name": "왼쪽"
},
"right": {
"name": "오른쪽"
}
},
"outputs": {
"0": {
"name": "긍정적"
},
"1": {
"name": "부정적"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2589,9 +2536,6 @@
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
},
"upload": {
"name": "업로드할 파일 선택"
}
@@ -4670,9 +4614,6 @@
"inputs": {
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
}
}
},
@@ -5153,28 +5094,11 @@
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
},
"filename_prefix": {
"name": "파일명 접두사"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "파일명 접두사"
},
"image": {
"name": "이미지"
},
"mesh": {
"name": "메시"
}
}
},
"SaveImage": {
"description": "입력 이미지를 ComfyUI 출력 디렉토리에 저장합니다.",
"display_name": "이미지 저장",
@@ -5781,23 +5705,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "옥트리 해상도"
},
"samples": {
"name": "샘플"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE 디코드 (타일)",
"inputs": {
@@ -5945,17 +5852,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "임계값"
},
"voxel": {
"name": "복셀"
}
}
},
"WanImageToVideo": {
"display_name": "Wan 이미지를 비디오로",
"inputs": {

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "Менеджер Пользовательских Узлов"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -74,7 +74,6 @@
"LOAD_3D": "ЗАГРУЗИТЬ_3D",
"LOAD_3D_ANIMATION": "ЗАГРУЗИТЬ_3D_АНИМАЦИЮ",
"MASK": "МАСКА",
"MESH": "СЕТКА",
"MODEL": "МОДЕЛЬ",
"NOISE": "ШУМ",
"PHOTOMAKER": "PHOTOMAKER",
@@ -85,7 +84,6 @@
"TIMESTEPS_RANGE": "ДИАПАЗОН_ВРЕМЕННЫХАГОВ",
"UPSCALE_MODEL": "МОДЕЛЬ_АПСКЕЙЛА",
"VAE": "VAE",
"VOXEL": "ВОКСЕЛ",
"WEBCAM": "ВЕБ-КАМЕРА"
},
"desktopMenu": {
@@ -190,6 +188,7 @@
"reportSent": "Отчёт отправлен",
"reset": "Сбросить",
"resetKeybindingsTooltip": "Сбросить сочетания клавиш по умолчанию",
"restart": "Перезапустить",
"resultsCount": "Найдено {count} результатов",
"save": "Сохранить",
"saving": "Сохранение",
@@ -268,7 +267,6 @@
"installLocationDescription": "Выберите директорию для пользовательских данных ComfyUI. В выбранном месте будет установлена среда Python. Пожалуйста, убедитесь, что на выбранном диске достаточно места (~15 ГБ).",
"installLocationTooltip": "Директория пользовательских данных ComfyUI. Хранит:\n- Среда Python\n- Модели\n- Пользовательские ноды\n",
"insufficientFreeSpace": "Недостаточно места — минимально необходимое свободное место",
"isOneDrive": "Установка в OneDrive может вызвать проблемы. Настоятельно рекомендуем устанавливать в месте, не связанном с OneDrive.",
"manualConfiguration": {
"createVenv": "Вам потребуется создать виртуальное окружение в следующем каталоге",
"requirements": "Требования",
@@ -283,7 +281,6 @@
"migrationOptional": "Миграция является необязательной. Если у вас нет существующей установки, вы можете пропустить этот шаг.",
"migrationSourcePathDescription": "Если у вас уже есть установленный ComfyUI, мы можем скопировать/связать ваши существующие пользовательские файлы и модели с новой установкой. Ваша существующая установка ComfyUI не будет затронута.",
"moreInfo": "Для получения дополнительной информации, пожалуйста, прочтите нашу",
"nonDefaultDrive": "Пожалуйста, установите ComfyUI на системный диск (например, C:\\). Диски с другими файловыми системами могут вызвать непредсказуемые проблемы. Модели и другие файлы можно хранить на других дисках после установки.",
"parentMissing": "Путь не существует — сначала создайте родительский каталог",
"pathExists": "Директория уже существует — пожалуйста, убедитесь, что вы сделали резервное копирование всех данных",
"pathValidationFailed": "Не удалось проверить путь",
@@ -401,6 +398,7 @@
"nodePack": "Пакет Узлов",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
"restartToApplyChanges": "Чтобы применить изменения, пожалуйста, перезапустите ComfyUI",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"sort": {
@@ -534,6 +532,7 @@
"Toggle Logs Bottom Panel": "Переключение нижней панели журналов",
"Toggle Model Library Sidebar": "Переключение боковой панели библиотеки моделей",
"Toggle Node Library Sidebar": "Переключение боковой панели библиотеки нод",
"Toggle Progress Dialog": "Переключить диалоговое окно прогресса",
"Toggle Queue Sidebar": "Переключение боковой панели очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Переключение нижней панели терминала",
@@ -884,9 +883,6 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Многовидовой",
"hunyuan-3d-turbo": "Hunyuan3D Турбо",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,18 +1251,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "размер_пакета",
"tooltip": "Количество скрытых изображений в пакете."
},
"resolution": {
"name": "разрешение"
}
}
},
"EmptyLatentImage": {
"description": "Создаёт новую партию пустых латентных изображений для удаления шума через выборку.",
"display_name": "Пустое латентное изображение",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "выход_clip_vision"
}
},
"outputs": {
"0": {
"name": "положительный"
},
"1": {
"name": "отрицательный"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "сзади"
},
"front": {
"name": "фронт"
},
"left": {
"name": "слева"
},
"right": {
"name": "справа"
}
},
"outputs": {
"0": {
"name": "положительный"
},
"1": {
"name": "отрицательный"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2589,9 +2536,6 @@
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "выберите файл для загрузки"
}
@@ -4670,9 +4614,6 @@
"inputs": {
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5153,28 +5094,11 @@
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "префиксазвания_файла"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "префикс_имени_файла"
},
"image": {
"name": "изображение"
},
"mesh": {
"name": "сетка"
}
}
},
"SaveImage": {
"description": "Сохраняет входные изображения в вашу директорию вывода ComfyUI.",
"display_name": "Сохранить изображение",
@@ -5781,23 +5705,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": оличествоастей"
},
"octree_resolution": {
"name": "разрешение_октодерева"
},
"samples": {
"name": "образцы"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "Декодировать VAE (плитками)",
"inputs": {
@@ -5945,17 +5852,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "порог"
},
"voxel": {
"name": "воксель"
}
}
},
"WanImageToVideo": {
"display_name": "WanИзображениеВВидео",
"inputs": {

View File

@@ -128,6 +128,9 @@
"Comfy_Manager_CustomNodesManager": {
"label": "自定义节点管理器"
},
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -74,7 +74,6 @@
"LOAD_3D": "加载3D",
"LOAD_3D_ANIMATION": "加载3D动画",
"MASK": "遮罩",
"MESH": "网格",
"MODEL": "模型",
"NOISE": "噪波",
"PHOTOMAKER": "PhotoMaker",
@@ -85,7 +84,6 @@
"TIMESTEPS_RANGE": "时间间隔范围",
"UPSCALE_MODEL": "放大模型",
"VAE": "VAE",
"VOXEL": "体素",
"WEBCAM": "摄像头"
},
"desktopMenu": {
@@ -190,6 +188,7 @@
"reportSent": "报告已提交",
"reset": "重置",
"resetKeybindingsTooltip": "将快捷键重置为默认",
"restart": "重新启动",
"resultsCount": "找到 {count} 个结果",
"save": "保存",
"saving": "正在保存",
@@ -268,7 +267,6 @@
"installLocationDescription": "选择 ComfyUI 用户数据的存放目录。将安装一个 Python 环境到所选位置。请确保所选磁盘有足够的空间(约 15GB。",
"installLocationTooltip": "ComfyUI 的用户数据目录。存储:\n- Python 环境\n- 模型\n- 自定义节点\n",
"insufficientFreeSpace": "空间不足 - 最小可用空间",
"isOneDrive": "在OneDrive中安装可能会导致问题。强烈建议在非OneDrive位置安装。",
"manualConfiguration": {
"createVenv": "您需要在以下目录中创建虚拟环境",
"requirements": "依赖项",
@@ -283,7 +281,6 @@
"migrationOptional": "迁移是可选的。如果您之前没有安装过 ComfyUI可以跳过此步骤。",
"migrationSourcePathDescription": "如果您已有现有的ComfyUI安装我们可以复制/链接您现有的用户文件和模型到新的安装。您现有的ComfyUI安装将不会受到影响。",
"moreInfo": "有关更多信息,请阅读我们的",
"nonDefaultDrive": "请在您的系统驱动器上安装ComfyUI例如C:\\)。具有不同文件系统的驱动器可能会导致不可预测的问题。安装后,模型和其他文件可以存储在其他驱动器上。",
"parentMissing": "路径不存在 - 请先创建包含该路径的目录",
"pathExists": "目录已存在 - 请确保您已备份全部数据",
"pathValidationFailed": "路径验证失败",
@@ -401,6 +398,7 @@
"nodePack": "节点包",
"packsSelected": "选定的包",
"repository": "仓库",
"restartToApplyChanges": "要应用更改请重新启动ComfyUI",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"sort": {
@@ -534,6 +532,7 @@
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切换模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Progress Dialog": "切换进度对话框",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
@@ -884,9 +883,6 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D多视图",
"hunyuan-3d-turbo": "Hunyuan3D Turbo",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "稳定Zero123"
},
"Area Composition": {

View File

@@ -1251,18 +1251,6 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "批量大小",
"tooltip": "批量中的潜在图像数量。"
},
"resolution": {
"name": "分辨率"
}
}
},
"EmptyLatentImage": {
"description": "创建一批新的空Latent图像以通过采样进行降噪。",
"display_name": "空Latent图像",
@@ -1484,47 +1472,6 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip视觉输出"
}
},
"outputs": {
"0": {
"name": "正向"
},
"1": {
"name": "反向"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "后"
},
"front": {
"name": "前"
},
"left": {
"name": "左"
},
"right": {
"name": "右"
}
},
"outputs": {
"0": {
"name": "正向"
},
"1": {
"name": "反向"
}
}
},
"HunyuanImageToVideo": {
"display_name": "Hunyuan图像到视频",
"inputs": {
@@ -2589,9 +2536,6 @@
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
},
"upload": {
"name": "选择文件上传"
}
@@ -4670,9 +4614,6 @@
"inputs": {
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
}
}
},
@@ -5153,28 +5094,11 @@
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
},
"filename_prefix": {
"name": "文件名前缀"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "文件名前缀"
},
"image": {
"name": "图像"
},
"mesh": {
"name": "网格"
}
}
},
"SaveImage": {
"description": "将输入图像保存到您的ComfyUI输出目录。",
"display_name": "保存图像",
@@ -5781,23 +5705,6 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "块数"
},
"octree_resolution": {
"name": "八叉树分辨率"
},
"samples": {
"name": "样本"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE解码分块",
"inputs": {
@@ -5945,17 +5852,6 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "阈值"
},
"voxel": {
"name": "体素"
}
}
},
"WanImageToVideo": {
"display_name": "Wan图像到视频",
"inputs": {

View File

@@ -23,7 +23,6 @@ import {
import { type ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -1392,18 +1391,6 @@ export class ComfyApp {
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'model/gltf-binary' ||
file.name?.endsWith('.glb')
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'application/json' ||
file.name?.endsWith('.json')

View File

@@ -63,6 +63,7 @@ export interface DOMWidgetOptions<V extends object | string>
getMaxHeight?: () => number
getHeight?: () => string | number
onDraw?: (widget: BaseDOMWidget<V>) => void
margin?: number
/**
* @deprecated Use `afterResize` instead. This callback is a legacy API
* that fires before resize happens, but it is no longer supported. Now it

View File

@@ -1,170 +0,0 @@
import {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/schemas/comfyWorkflowSchema'
import {
ASCII,
ComfyMetadata,
ComfyMetadataTags,
GltfChunkHeader,
GltfHeader,
GltfJsonData,
GltfSizeBytes
} from '@/types/metadataTypes'
const MAX_READ_BYTES = 1 << 20
const isJsonChunk = (chunk: GltfChunkHeader | null): boolean =>
!!chunk && chunk.chunkTypeIdentifier === ASCII.JSON
const isValidChunkRange = (
start: number,
length: number,
bufferSize: number
): boolean => start + length <= bufferSize
const byteArrayToString = (bytes: Uint8Array): string =>
new TextDecoder().decode(bytes)
const parseGltfBinaryHeader = (dataView: DataView): GltfHeader | null => {
if (dataView.byteLength < GltfSizeBytes.HEADER) return null
const magicNumber = dataView.getUint32(0, true)
if (magicNumber !== ASCII.GLTF) return null
return {
magicNumber,
gltfFormatVersion: dataView.getUint32(4, true),
totalLengthBytes: dataView.getUint32(8, true)
}
}
const parseChunkHeaderAtOffset = (
dataView: DataView,
offset: number
): GltfChunkHeader | null => {
if (offset + GltfSizeBytes.CHUNK_HEADER > dataView.byteLength) return null
return {
chunkLengthBytes: dataView.getUint32(offset, true),
chunkTypeIdentifier: dataView.getUint32(offset + 4, true)
}
}
const extractJsonChunk = (
buffer: ArrayBuffer
): { start: number; length: number } | null => {
const dataView = new DataView(buffer)
const header = parseGltfBinaryHeader(dataView)
if (!header) return null
const chunkOffset = GltfSizeBytes.HEADER
const firstChunk = parseChunkHeaderAtOffset(dataView, chunkOffset)
if (!firstChunk || !isJsonChunk(firstChunk)) return null
const jsonStart = chunkOffset + GltfSizeBytes.CHUNK_HEADER
const isValid = isValidChunkRange(
jsonStart,
firstChunk.chunkLengthBytes,
dataView.byteLength
)
if (!isValid) return null
return { start: jsonStart, length: firstChunk.chunkLengthBytes }
}
const extractJsonChunkData = (buffer: ArrayBuffer): Uint8Array | null => {
const chunkLocation = extractJsonChunk(buffer)
if (!chunkLocation) return null
return new Uint8Array(buffer, chunkLocation.start, chunkLocation.length)
}
const parseJson = (text: string): ReturnType<typeof JSON.parse> | null => {
try {
return JSON.parse(text)
} catch {
return null
}
}
const parseJsonBytes = (
bytes: Uint8Array
): ReturnType<typeof JSON.parse> | null => {
const jsonString = byteArrayToString(bytes)
return parseJson(jsonString)
}
const parseMetadataValue = (
value: string | object
): ComfyWorkflowJSON | ComfyApiWorkflow | undefined => {
if (typeof value !== 'string')
return value as ComfyWorkflowJSON | ComfyApiWorkflow
const parsed = parseJson(value)
if (!parsed) return undefined
return parsed as ComfyWorkflowJSON | ComfyApiWorkflow
}
const extractComfyMetadata = (jsonData: GltfJsonData): ComfyMetadata => {
const metadata: ComfyMetadata = {}
if (!jsonData?.asset?.extras) return metadata
const { extras } = jsonData.asset
if (extras.workflow) {
const parsedValue = parseMetadataValue(extras.workflow)
if (parsedValue) {
metadata[ComfyMetadataTags.WORKFLOW.toLowerCase()] = parsedValue
}
}
if (extras.prompt) {
const parsedValue = parseMetadataValue(extras.prompt)
if (parsedValue) {
metadata[ComfyMetadataTags.PROMPT.toLowerCase()] = parsedValue
}
}
return metadata
}
const processGltfFileBuffer = (buffer: ArrayBuffer): ComfyMetadata => {
const jsonChunk = extractJsonChunkData(buffer)
if (!jsonChunk) return {}
const parsedJson = parseJsonBytes(jsonChunk)
if (!parsedJson) return {}
return extractComfyMetadata(parsedJson)
}
/**
* Extract ComfyUI metadata from a GLTF binary file (GLB)
*/
export function getGltfBinaryMetadata(file: File): Promise<ComfyMetadata> {
return new Promise<ComfyMetadata>((resolve) => {
if (!file) return Promise.resolve({})
const bytesToRead = Math.min(file.size, MAX_READ_BYTES)
const reader = new FileReader()
reader.onload = (event) => {
try {
if (!event.target?.result) {
resolve({})
return
}
resolve(processGltfFileBuffer(event.target.result as ArrayBuffer))
} catch {
resolve({})
}
}
reader.onerror = () => resolve({})
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
})
}

View File

@@ -131,7 +131,9 @@ export const useAlgoliaSearchService = () => {
status: algoliaNode.status,
icon: algoliaNode.icon_url,
latest_version: toRegistryLatestVersion(algoliaNode),
publisher: toRegistryPublisher(algoliaNode)
publisher: toRegistryPublisher(algoliaNode),
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
comfy_nodes: algoliaNode.comfy_nodes
}
}

View File

@@ -2,11 +2,14 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import ExecutionErrorDialogContent from '@/components/dialog/content/ExecutionErrorDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
@@ -121,6 +124,29 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: '!p-0' },
header: { class: '!p-0 border-none' },
footer: { class: '!p-0 border-none' }
}
}
})
}
async function prompt({
title,
message,
@@ -203,6 +229,7 @@ export const useDialogService = () => {
showTemplateWorkflowsDialog,
showIssueReportDialog,
showManagerDialog,
showManagerProgressDialog,
prompt,
confirm
}

View File

@@ -6,6 +6,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { useServerLogs } from '@/composables/useServerLogs'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import {
InstallPackParams,
InstalledPacksResponse,
@@ -20,6 +21,8 @@ import {
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set())
const disabledPacksIds = ref<Set<string>>(new Set())
@@ -110,6 +113,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
}
whenever(isStale, refreshInstalledList, { immediate: true })
whenever(uncompletedCount, () => showManagerProgressDialog())
const withLogs = (task: () => Promise<null>, taskName: string) => {
const { startListening, stopListening, logs } = useServerLogs()
@@ -178,6 +182,11 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enqueueTask(withLogs(task, `Disabling ${params.id}`))
}
const getInstalledPackVersion = (packId: string) => {
const pack = installedPacks.value[packId]
return pack?.ver
}
const clearLogs = () => {
taskLogs.value = []
}
@@ -197,6 +206,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
installedPacksIds,
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
// Pack actions
installPack,
@@ -207,3 +217,33 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enablePack: installPack // Enable is done via install endpoint with a disabled pack
}
})
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand
}
}
)

View File

@@ -7,11 +7,24 @@ import { type Component, markRaw, ref } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
type DialogPosition =
| 'center'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'topleft'
| 'topright'
| 'bottomleft'
| 'bottomright'
interface CustomDialogComponentProps {
maximizable?: boolean
maximized?: boolean
onClose?: () => void
closable?: boolean
modal?: boolean
position?: DialogPosition
pt?: DialogPassThroughOptions
}
@@ -25,6 +38,7 @@ interface DialogInstance {
headerComponent?: Component
component: Component
contentProps: Record<string, any>
footerComponent?: Component
dialogComponentProps: DialogComponentProps
}
@@ -32,6 +46,7 @@ export interface ShowDialogOptions {
key?: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
@@ -66,6 +81,7 @@ export const useDialogStore = defineStore('dialog', () => {
key: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
@@ -81,6 +97,9 @@ export const useDialogStore = defineStore('dialog', () => {
headerComponent: options.headerComponent
? markRaw(options.headerComponent)
: undefined,
footerComponent: options.footerComponent
? markRaw(options.footerComponent)
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
dialogComponentProps: {

View File

@@ -73,14 +73,12 @@ export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
)
}
const addNewBookmarkFolder = (parent?: ComfyNodeDefImpl) => {
const addNewBookmarkFolder = (
parent: ComfyNodeDefImpl | undefined,
folderName: string
) => {
const parentPath = parent ? parent.nodePath : ''
let newFolderPath = parentPath + 'New Folder/'
let suffix = 1
while (bookmarks.value.some((b: string) => b.startsWith(newFolderPath))) {
newFolderPath = parentPath + `New Folder ${suffix}/`
suffix++
}
const newFolderPath = parentPath + folderName + '/'
addBookmark(newFolderPath)
return newFolderPath
}

View File

@@ -13,6 +13,7 @@ import { UserFile } from './userFileStore'
export class ComfyWorkflow extends UserFile {
static readonly basePath = 'workflows/'
static readonly folderPlaceholderFilename = 'folder.index'
/**
* The change tracker for the workflow. Non-reactive raw object.
@@ -32,7 +33,9 @@ export class ComfyWorkflow extends UserFile {
}
get key() {
return this.path.substring(ComfyWorkflow.basePath.length)
const key = this.isFolderPlaceholder ? this.directory + '/' : this.path
return key.substring(ComfyWorkflow.basePath.length)
}
get activeState(): ComfyWorkflowJSON | null {
@@ -55,6 +58,13 @@ export class ComfyWorkflow extends UserFile {
this._isModified = value
}
/**
* Whether the workflow is a folder placeholder.
*/
get isFolderPlaceholder(): boolean {
return this.fullFilename === ComfyWorkflow.folderPlaceholderFilename
}
/**
* Load the workflow content from remote storage. Directly returns the loaded
* workflow if the content is already loaded.
@@ -153,6 +163,8 @@ export interface WorkflowStore {
getWorkflowByPath: (path: string) => ComfyWorkflow | null
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
createFolder: (folderPath: string) => Promise<void>
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -418,6 +430,40 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/**
* Creates a new folder in the workflows directory
* @param folderPath The path of the folder to create (relative to workflows/)
* @returns Promise that resolves when the folder is created
*/
const createFolder = async (folderPath: string): Promise<void> => {
isBusy.value = true
try {
// Ensure the path is properly formatted
const normalizedPath = folderPath.endsWith('/')
? folderPath.slice(0, -1)
: folderPath
// Create the full path including the reserved index file
const indexFilePath = `${ComfyWorkflow.basePath}${normalizedPath}/${ComfyWorkflow.folderPlaceholderFilename}`
// Create an empty file to represent the folder
const resp = await api.storeUserData(indexFilePath, '', {
overwrite: false,
throwOnError: true,
full_info: true
})
if (resp.status !== 200) {
throw new Error('Failed to create folder')
}
// Sync workflows to update the file tree
await syncWorkflows()
} finally {
isBusy.value = false
}
}
return {
activeWorkflow,
isActive,
@@ -439,7 +485,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
persistedWorkflows,
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows
syncWorkflows,
createFolder
}
}) as unknown as () => WorkflowStore

View File

@@ -43,38 +43,3 @@ export type TextRange = {
start: number
end: number
}
export enum ASCII {
GLTF = 0x46546c67,
JSON = 0x4e4f534a
}
export enum GltfSizeBytes {
HEADER = 12,
CHUNK_HEADER = 8
}
export type GltfHeader = {
magicNumber: number
gltfFormatVersion: number
totalLengthBytes: number
}
export type GltfChunkHeader = {
chunkLengthBytes: number
chunkTypeIdentifier: number
}
export type GltfExtras = {
workflow?: string | object
prompt?: string | object
[key: string]: any
}
export type GltfJsonData = {
asset?: {
extras?: GltfExtras
[key: string]: any
}
[key: string]: any
}

View File

@@ -1,4 +1,5 @@
import type { MenuItem } from 'primevue/menuitem'
import type { InjectionKey, Ref } from 'vue'
export interface TreeExplorerNode<T = any> {
key: string
@@ -18,10 +19,10 @@ export interface TreeExplorerNode<T = any> {
) => void | Promise<void>
/** Function to handle deleting the node */
handleDelete?: (this: TreeExplorerNode<T>) => void | Promise<void>
/** Function to handle adding a child node */
handleAddChild?: (
/** Function to handle adding a folder */
handleAddFolder?: (
this: TreeExplorerNode<T>,
child: TreeExplorerNode<T>
folderName: string
) => void | Promise<void>
/** Whether the node is draggable */
draggable?: boolean
@@ -58,9 +59,18 @@ export interface RenderedTreeExplorerNode<T = any> extends TreeExplorerNode<T> {
totalLeaves: number
/** Text to display on the leaf-count badge. Empty string means no badge. */
badgeText?: string
/** Whether the node label is currently being edited */
isEditingLabel?: boolean
}
export type TreeExplorerDragAndDropData<T = any> = {
type: 'tree-explorer-node'
data: RenderedTreeExplorerNode<T>
}
export const InjectKeyHandleEditLabelFunction: InjectionKey<
(node: RenderedTreeExplorerNode, newName: string) => void
> = Symbol()
export const InjectKeyExpandedKeys: InjectionKey<Ref<Record<string, boolean>>> =
Symbol()

View File

@@ -384,3 +384,31 @@ export const downloadUrlToHfRepoUrl = (url: string): string => {
return url
}
}
export const isSemVer = (version: string) => {
const regex = /^(\d+)\.(\d+)\.(\d+)$/
return regex.test(version)
}
const normalizeVersion = (version: string) =>
version
.split(/[+.-]/)
.map(Number)
.filter((part) => !Number.isNaN(part))
export function compareVersions(versionA: string, versionB: string): number {
versionA ??= '0.0.0'
versionB ??= '0.0.0'
const aParts = normalizeVersion(versionA)
const bParts = normalizeVersion(versionB)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart < bPart) return -1
if (aPart > bPart) return 1
}
return 0
}

View File

@@ -105,7 +105,10 @@ export function sortedTree(
return newNode
}
export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => {
export const findNodeByKey = <T extends TreeNode>(
root: T,
key: string
): T | null => {
if (root.key === key) {
return root
}
@@ -113,10 +116,46 @@ export const findNodeByKey = (root: TreeNode, key: string): TreeNode | null => {
return null
}
for (const child of root.children) {
const result = findNodeByKey(child, key)
const result = findNodeByKey(child as T, key)
if (result) {
return result
}
}
return null
}
/**
* Deep clone a tree node and its children.
* @param node - The node to clone.
* @returns A deep clone of the node.
*/
export function cloneTree<T extends TreeNode>(node: T): T {
const clone: T = { ...node } as T
// Clone children recursively
if (node.children && node.children.length > 0) {
clone.children = node.children.map((child) => cloneTree(child as T))
}
return clone
}
/**
* Merge a subtree into the tree.
* @param root - The root of the tree.
* @param subtree - The subtree to merge.
* @returns A new tree with the subtree merged.
*/
export const combineTrees = <T extends TreeNode>(root: T, subtree: T): T => {
const newRoot = cloneTree(root)
const parentKey = subtree.key.slice(0, subtree.key.lastIndexOf('/'))
const parent = findNodeByKey(newRoot, parentKey)
if (parent) {
parent.children ??= []
parent.children.push(cloneTree(subtree))
}
return newRoot
}

View File

@@ -1,163 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
import { getGltfBinaryMetadata } from '../../../../src/scripts/metadata/gltf'
describe('GLTF binary metadata parser', () => {
const createGLTFFileStructure = () => {
const header = new ArrayBuffer(GltfSizeBytes.HEADER)
const headerView = new DataView(header)
return { header, headerView }
}
const jsonToBinary = (json: object) => {
const jsonString = JSON.stringify(json)
const jsonData = new TextEncoder().encode(jsonString)
return jsonData
}
const createJSONChunk = (jsonData: ArrayBuffer) => {
const chunkHeader = new ArrayBuffer(GltfSizeBytes.CHUNK_HEADER)
const chunkView = new DataView(chunkHeader)
chunkView.setUint32(0, jsonData.byteLength, true)
chunkView.setUint32(4, ASCII.JSON, true)
return chunkHeader
}
const setVersionHeader = (headerView: DataView, version: number) => {
headerView.setUint32(4, version, true)
}
const setTypeHeader = (headerView: DataView, type: number) => {
headerView.setUint32(0, type, true)
}
const setTotalLengthHeader = (headerView: DataView, length: number) => {
headerView.setUint32(8, length, true)
}
const setHeaders = (headerView: DataView, jsonData: ArrayBuffer) => {
setTypeHeader(headerView, ASCII.GLTF)
setVersionHeader(headerView, 2)
setTotalLengthHeader(
headerView,
GltfSizeBytes.HEADER + GltfSizeBytes.CHUNK_HEADER + jsonData.byteLength
)
}
function createMockGltfFile(jsonContent: object): File {
const jsonData = jsonToBinary(jsonContent)
const { header, headerView } = createGLTFFileStructure()
setHeaders(headerView, jsonData)
const chunkHeader = createJSONChunk(jsonData)
const fileContent = new Uint8Array(
header.byteLength + chunkHeader.byteLength + jsonData.byteLength
)
fileContent.set(new Uint8Array(header), 0)
fileContent.set(new Uint8Array(chunkHeader), header.byteLength)
fileContent.set(jsonData, header.byteLength + chunkHeader.byteLength)
return new File([fileContent], 'test.glb', { type: 'model/gltf-binary' })
}
it('should extract workflow metadata from GLTF binary file', async () => {
const testWorkflow = {
nodes: [
{
id: 1,
type: 'TestNode',
pos: [100, 100]
}
],
links: []
}
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
generator: 'ComfyUI GLTF Test',
extras: {
workflow: testWorkflow
}
},
scenes: []
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.workflow).toBeDefined()
const workflow = metadata.workflow as {
nodes: Array<{ id: number; type: string }>
}
expect(workflow.nodes[0].id).toBe(1)
expect(workflow.nodes[0].type).toBe('TestNode')
})
it('should extract prompt metadata from GLTF binary file', async () => {
const testPrompt = {
node1: {
class_type: 'TestNode',
inputs: {
seed: 123456
}
}
}
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
generator: 'ComfyUI GLTF Test',
extras: {
prompt: testPrompt
}
},
scenes: []
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.prompt).toBeDefined()
const prompt = metadata.prompt as Record<string, any>
expect(prompt.node1.class_type).toBe('TestNode')
expect(prompt.node1.inputs.seed).toBe(123456)
})
it('should handle string JSON content', async () => {
const workflowStr = JSON.stringify({
nodes: [{ id: 1, type: 'StringifiedNode' }],
links: []
})
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
extras: {
workflow: workflowStr // As string instead of object
}
}
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.workflow).toBeDefined()
const workflow = metadata.workflow as {
nodes: Array<{ id: number; type: string }>
}
expect(workflow.nodes[0].type).toBe('StringifiedNode')
})
it('should handle invalid GLTF binary files gracefully', async () => {
const invalidEmptyFile = new File([], 'invalid.glb')
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
expect(metadata).toEqual({})
})
})