Add Subgraphs (#3905)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
filtered
2025-06-28 15:37:23 -07:00
committed by GitHub
parent 7620bb9063
commit a7fb685290
53 changed files with 1187 additions and 247 deletions

View File

@@ -17,11 +17,11 @@ test.describe('Group Node', () => {
await libraryTab.open()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
test.skip('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
test.skip('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
@@ -34,7 +34,7 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
test.skip('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -61,7 +61,7 @@ test.describe('Group Node', () => {
).toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
test.skip('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
@@ -95,7 +95,7 @@ test.describe('Group Node', () => {
)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
test.skip('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
@@ -104,7 +104,7 @@ test.describe('Group Node', () => {
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
test.skip('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name, type1, type2) => {
@@ -165,7 +165,7 @@ test.describe('Group Node', () => {
expect(visibleInputCount).toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
test.skip('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {

View File

@@ -24,7 +24,7 @@ test.describe('Canvas Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
})
test('Can convert to group node', async ({ comfyPage }) => {
test.skip('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.select2Nodes()
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.rightClickCanvas()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

8
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.15",
"@comfyorg/litegraph": "^0.16.0",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -948,9 +948,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.15.15",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.15.tgz",
"integrity": "sha512-otOKgTxNPV6gEa6PW1fHGMMF8twjnZkP0vWQhGsRISK4vN8tPfX8O9sC9Hnq3nV8axaMv4/Ff49+7mMVcFEKeA==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0.tgz",
"integrity": "sha512-67JiZgG7MZtQ+IxeKlm17Cs4XGXXBlKV0unDM0BvgGOZUtqY8V/K6omsyZRrnZeq0iT+muQEDE/CluWaS/wzdw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -76,7 +76,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.15",
"@comfyorg/litegraph": "^0.16.0",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -1,8 +1,5 @@
<template>
<div
v-if="workflowStore.isSubgraphActive"
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<Breadcrumb
class="bg-transparent"
:home="home"
@@ -14,28 +11,30 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const items = computed(() => {
if (!workflowStore.subgraphNamePath.length) return []
if (!navigationStore.navigationStack.length) return []
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
label: name,
command: async () => {
const workflow = workflowStore.getWorkflowByPath(name)
if (workflow) await workflowService.openWorkflow(workflow)
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph)
}
}))
})
@@ -43,7 +42,7 @@ const items = computed(() => {
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
command: async () => {
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -55,22 +54,32 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
)
})
</script>
<style>
.subgraph-breadcrumb {
.p-breadcrumb-item-link,
.p-breadcrumb-item-icon {
@apply select-none;
color: #d26565;
user-select: none;
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
0 0 0.375rem #000;
}
}
</style>

View File

@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widgetState of domWidgetStore.widgetStates.values()) {
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode

View File

@@ -12,10 +12,12 @@
<BottomPanel />
</template>
<template #graph-canvas-panel>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
class="pointer-events-auto"
/>
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
</LiteGraphCanvasSplitterOverlay>
@@ -39,12 +41,11 @@
</SelectionOverlay>
<DomWidgets />
</template>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -192,10 +194,10 @@ watch(
// Update the progress of the executing node
watch(
() =>
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
NodeId | null,
number | null
],
[
executionStore.executingNodeId,
executionStore.executingNodeProgress
] satisfies [NodeId | null, number | null],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
@@ -339,6 +341,16 @@ onMounted(async () => {
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})
</script>

View File

@@ -12,6 +12,7 @@
<PinButton />
<EditModelButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
@@ -29,6 +30,7 @@ import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import EditModelButton from '@/components/graph/selectionToolbox/EditModelButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'

View File

@@ -0,0 +1,34 @@
<template>
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-box"
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||
canvasStore.nodeSelected
)
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<Button
v-show="isDeletable"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
@@ -13,10 +14,17 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
)
</script>

View File

@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isImageNode)
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
})
const openMaskEditor = () => {

View File

@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
return
}
disconnectOnReset = false
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
if (disconnectOnReset) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
}
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute top-0 left-0 w-auto max-w-full">
<div class="w-auto max-w-full">
<WorkflowTabs />
</div>
</template>

View File

@@ -19,7 +19,7 @@ import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -38,6 +38,7 @@ export function useCoreCommands(): ComfyCommand[] {
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -730,6 +731,30 @@ export function useCoreCommands(): ComfyCommand[] {
if (!(node instanceof LGraphNode)) return
await addFluxKontextGroupNode(node)
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
const { node } = res
canvas.select(node)
}
}
]

View File

@@ -9,6 +9,7 @@ export function useErrorHandling() {
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.unknownError')
})
console.error(error)
}
const wrapWithErrorHandling =

View File

@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',
ctrl: true,
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
}
]

View File

@@ -1,4 +1,4 @@
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { t } from '@/i18n'
@@ -1583,57 +1583,6 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1723,9 +1672,6 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "Give Feedback"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},

View File

@@ -839,6 +839,7 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1324,7 +1325,9 @@
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
"nothingSelected": "Nothing selected",
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
},
"auth": {
"apiKey": {

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "Dar retroalimentación"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "Foro de ComfyUI",
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Delete Selected Items": "Eliminar elementos seleccionados",
"Desktop User Guide": "Guía de usuario de escritorio",
@@ -1389,6 +1390,7 @@
"title": "Comienza con una Plantilla"
},
"toastMessages": {
"cannotCreateSubgraph": "No se puede crear el subgrafo",
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
"dropFileError": "No se puede procesar el elemento soltado: {error}",
"emptyCanvas": "Lienzo vacío",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
"failedToApplyTexture": "Error al aplicar textura",
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
"failedToDownloadFile": "Error al descargar el archivo",
"failedToExportModel": "Error al exportar modelo como {format}",

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "Retour d'information"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
"Desktop User Guide": "Guide de l'utilisateur de bureau",
@@ -1389,6 +1390,7 @@
"title": "Commencez avec un modèle"
},
"toastMessages": {
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
"emptyCanvas": "Toile vide",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
"failedToApplyTexture": "Échec de l'application de la texture",
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
"failedToCreateCustomer": "Échec de la création du client : {error}",
"failedToDownloadFile": "Échec du téléchargement du fichier",
"failedToExportModel": "Échec de l'exportation du modèle en {format}",

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "フィードバック"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Delete Selected Items": "選択したアイテムを削除",
"Desktop User Guide": "デスクトップユーザーガイド",
@@ -1389,6 +1390,7 @@
"title": "テンプレートを利用して開始"
},
"toastMessages": {
"cannotCreateSubgraph": "サブグラフを作成できません",
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
"emptyCanvas": "キャンバスが空です",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "設定{id}の保存エラー: {err}",
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
"failedToApplyTexture": "テクスチャの適用に失敗しました",
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "피드백"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Delete Selected Items": "선택한 항목 삭제",
"Desktop User Guide": "데스크톱 사용자 가이드",
@@ -1389,6 +1390,7 @@
"title": "템플릿으로 시작하기"
},
"toastMessages": {
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
"emptyCanvas": "빈 캔버스",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "Обратная связь"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Delete Selected Items": "Удалить выбранные элементы",
"Desktop User Guide": "Руководство пользователя для настольных ПК",
@@ -1389,6 +1390,7 @@
"title": "Начните с шаблона"
},
"toastMessages": {
"cannotCreateSubgraph": "Невозможно создать подграф",
"couldNotDetermineFileType": "Не удалось определить тип файла",
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
"emptyCanvas": "Пустой холст",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
"failedToApplyTexture": "Не удалось применить текстуру",
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
"failedToDownloadFile": "Не удалось скачать файл",
"failedToExportModel": "Не удалось экспортировать модель как {format}",

View File

@@ -113,6 +113,9 @@
"Comfy_Feedback": {
"label": "反馈"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},

View File

@@ -714,6 +714,7 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Delete Selected Items": "删除选定的项目",
"Desktop User Guide": "桌面端用户指南",
@@ -1389,6 +1390,7 @@
"title": "从模板开始"
},
"toastMessages": {
"cannotCreateSubgraph": "无法创建子图",
"couldNotDetermineFileType": "无法确定文件类型",
"dropFileError": "无法处理掉落的项目:{error}",
"emptyCanvas": "画布为空",
@@ -1397,6 +1399,7 @@
"errorSaveSetting": "保存设置 {id} 出错:{err}",
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
"failedToApplyTexture": "应用纹理失败",
"failedToConvertToSubgraph": "无法将项目转换为子图",
"failedToCreateCustomer": "创建客户失败:{error}",
"failedToDownloadFile": "文件下载失败",
"failedToExportModel": "无法将模型导出为 {format}",

View File

@@ -41,10 +41,10 @@ const zModelFile = z.object({
const zGraphState = z
.object({
lastGroupid: z.number().optional(),
lastNodeId: z.number().optional(),
lastLinkId: z.number().optional(),
lastRerouteId: z.number().optional()
lastGroupId: z.number(),
lastNodeId: z.number(),
lastLinkId: z.number(),
lastRerouteId: z.number()
})
.passthrough()
@@ -214,6 +214,32 @@ const zComfyNode = z
})
.passthrough()
export const zSubgraphIO = zNodeInput.extend({
/** Slot ID (internal; never changes once instantiated). */
id: z.string().uuid(),
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: z.string(),
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
linkIds: z.array(z.number()).optional()
})
const zSubgraphInstance = z
.object({
id: zNodeId,
type: z.string().uuid(),
pos: zVector2,
size: zVector2,
flags: zFlags,
order: z.number(),
mode: z.number(),
inputs: z.array(zSubgraphIO).optional(),
outputs: z.array(zSubgraphIO).optional(),
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough()
const zGroup = z
.object({
id: z.number().optional(),
@@ -248,9 +274,22 @@ const zExtra = z
})
.passthrough()
export const zGraphDefinitions = z.object({
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
})
export const zBaseExportableGraph = z.object({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid().optional(),
revision: z.number().optional(),
config: zConfig.optional().nullable(),
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
subgraphs: z.array(zSubgraphInstance).optional()
})
/** Schema version 0.4 */
export const zComfyWorkflow = z
.object({
export const zComfyWorkflow = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
last_node_id: zNodeId,
@@ -262,13 +301,47 @@ export const zComfyWorkflow = z
config: zConfig.optional().nullable(),
extra: zExtra.optional().nullable(),
version: z.number(),
models: z.array(zModelFile).optional()
models: z.array(zModelFile).optional(),
definitions: zGraphDefinitions.optional()
})
.passthrough()
/** Required for recursive definition of subgraphs. */
interface ComfyWorkflow1BaseType {
id?: string
revision?: number
version: 1
models?: z.infer<typeof zModelFile>[]
state: z.infer<typeof zGraphState>
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
groups?: z.input<typeof zGroup>[]
nodes: z.input<typeof zComfyNode>[]
links?: z.input<typeof zComfyLinkObject>[]
floatingLinks?: z.input<typeof zComfyLinkObject>[]
reroutes?: z.input<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
}
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
groups?: z.output<typeof zGroup>[]
nodes: z.output<typeof zComfyNode>[]
links?: z.output<typeof zComfyLinkObject>[]
floatingLinks?: z.output<typeof zComfyLinkObject>[]
reroutes?: z.output<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
}
}
/** Schema version 1 */
export const zComfyWorkflow1 = z
.object({
export const zComfyWorkflow1 = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
version: z.literal(1),
@@ -280,7 +353,96 @@ export const zComfyWorkflow1 = z
floatingLinks: z.array(zComfyLinkObject).optional(),
reroutes: z.array(zReroute).optional(),
extra: zExtra.optional().nullable(),
models: z.array(zModelFile).optional()
models: z.array(zModelFile).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => z.array(zSubgraphDefinition)
)
})
.optional()
})
.passthrough()
export const zExportedSubgraphIONode = z.object({
id: zNodeId,
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
pinned: z.boolean().optional()
})
export const zExposedWidget = z.object({
id: z.string(),
name: z.string()
})
interface SubgraphDefinitionBase<
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
> {
/** Unique graph ID. Automatically generated if not provided. */
id: string
revision: number
name: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
outputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExposedWidget>[]
: z.output<typeof zExposedWidget>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<T>[]
}
}
/** A subgraph definition `worfklow.definitions.subgraphs` */
export const zSubgraphDefinition = zComfyWorkflow1
.extend({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs: z.array(zSubgraphIO).optional(),
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs: z.array(zSubgraphIO).optional(),
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets: z.array(zExposedWidget).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => zSubgraphDefinition.array()
)
})
.optional()
})
.passthrough()

View File

@@ -39,9 +39,11 @@ import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -72,7 +74,6 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import {
getFlacMetadata,
getLatentMetadata,
@@ -717,25 +718,23 @@ export class ComfyApp {
}
#addAfterConfigureHandler() {
const app = this
const onConfigure = app.graph.onConfigure
app.graph.onConfigure = function (this: LGraph, ...args) {
const { graph } = this
const { onConfigure } = graph
graph.onConfigure = function (...args) {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
for (const node of app.graph.nodes) {
for (const node of graph.nodes) {
node.onGraphConfigured?.()
}
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
for (const node of app.graph.nodes) {
for (const node of graph.nodes) {
node.onAfterGraphConfigured?.()
}
pruneWidgets(this.nodes)
return r
}
}
@@ -767,6 +766,21 @@ export class ComfyApp {
this.#graph = new LGraph()
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
this.graph.events.addEventListener('subgraph-created', (e) => {
try {
const { subgraph, data } = e.detail
useSubgraphService().registerNewSubgraph(subgraph, data)
} catch (err) {
console.error('Failed to register subgraph', err)
useToastStore().add({
severity: 'error',
summary: 'Failed to register subgraph',
detail: err instanceof Error ? err.message : String(err)
})
}
})
this.#addAfterConfigureHandler()
this.canvas = new LGraphCanvas(canvasEl, this.graph)
@@ -779,6 +793,30 @@ export class ComfyApp {
LiteGraph.alt_drag_do_clone_nodes = true
LiteGraph.macGesturesRequireMac = false
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
(e) => {
// Assertion: Not yet defined in litegraph.
const { newGraph } = e.detail
const nodeSet = new Set(newGraph.nodes)
const widgetStore = useDomWidgetStore()
// Assertions: UnwrapRef
for (const { widget } of widgetStore.activeWidgetStates) {
if (!nodeSet.has(widget.node)) {
widgetStore.deactivateWidget(widget.id)
}
}
for (const { widget } of widgetStore.inactiveWidgetStates) {
if (nodeSet.has(widget.node)) {
widgetStore.activateWidget(widget.id)
}
}
}
)
this.graph.start()
// Ensure the canvas fills the window
@@ -1015,6 +1053,7 @@ export class ComfyApp {
})
}
useWorkflowService().beforeLoadNewGraph()
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
@@ -1212,6 +1251,9 @@ export class ComfyApp {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
}
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
@@ -1254,9 +1296,13 @@ export class ComfyApp {
executeWidgetsCallback(
p.workflow.nodes
.map((n) => this.graph.getNodeById(n.id))
.filter((n) => !!n) as LGraphNode[],
.filter((n) => !!n),
'afterQueued'
)
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
}
this.canvas.draw(true, true)
await this.ui.queue.update()
}
@@ -1652,6 +1698,8 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
useDomWidgetStore().clear()
}
clientPosToCanvasPos(pos: Vector2): Vector2 {

View File

@@ -6,6 +6,7 @@ import log from 'loglevel'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { api } from './api'
@@ -37,6 +38,10 @@ export class ChangeTracker {
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
private subgraphState?: {
navigation: string[]
}
constructor(
/**
* The workflow that this change tracker is tracking
@@ -67,6 +72,8 @@ export class ChangeTracker {
scale: app.canvas.ds.scale,
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
}
const navigation = useSubgraphNavigationStore().exportState()
this.subgraphState = navigation.length ? { navigation } : undefined
}
restore() {
@@ -77,6 +84,16 @@ export class ChangeTracker {
if (this.nodeOutputs) {
app.nodeOutputs = this.nodeOutputs
}
if (this.subgraphState) {
const { navigation } = this.subgraphState
useSubgraphNavigationStore().restoreState(navigation)
const activeId = navigation.at(-1)
if (activeId) {
const subgraph = app.graph.subgraphs.get(activeId)
if (subgraph) app.canvas.setGraph(subgraph)
}
}
}
updateModified() {
@@ -376,7 +393,14 @@ export class ChangeTracker {
return false
// Compare other properties normally
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
for (const key of [
'links',
'floatingLinks',
'reroutes',
'groups',
'definitions',
'subgraphs'
]) {
if (!_.isEqual(a[key], b[key])) {
return false
}
@@ -392,7 +416,12 @@ export class ChangeTracker {
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes,
groups: graph.groups,
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id

View File

@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<V extends object | string>
export interface BaseDOMWidget<V extends object | string = object | string>
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
// ICustomWidget properties
type: string
@@ -330,9 +330,8 @@ LGraphNode.prototype.addDOMWidget = function <
export const pruneWidgets = (nodes: LGraphNode[]) => {
const nodeSet = new Set(nodes)
const domWidgetStore = useDomWidgetStore()
for (const widgetState of domWidgetStore.widgetStates.values()) {
const widget = widgetState.widget
if (!nodeSet.has(widget.node as LGraphNode)) {
for (const { widget } of domWidgetStore.widgetStates.values()) {
if (!nodeSet.has(widget.node)) {
domWidgetStore.unregisterWidget(widget.id)
}
}

View File

@@ -1,14 +1,18 @@
import {
type IContextMenuValue,
LGraphBadge,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
LiteGraph,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@comfyorg/litegraph'
import type {
ExportedSubgraphInstance,
ISerialisableNodeInput,
ISerialisableNodeOutput,
ISerialisedNode
@@ -35,6 +39,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isImageNode,
@@ -56,6 +61,267 @@ export const useLitegraphService = () => {
const widgetStore = useWidgetStore()
const canvasStore = useCanvasStore()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
function registerSubgraphNodeDef(
nodeDefV1: ComfyNodeDefV1,
subgraph: Subgraph,
instanceData: ExportedSubgraphInstance
) {
const node = class ComfyNode extends SubgraphNode {
static comfyClass: string
static override title: string
static override category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
constructor() {
super(app.graph, subgraph, instanceData)
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
this.badges.push(
new LGraphBadge({
text: '⇌',
fgColor: '#dad0de',
bgColor: '#b3b'
})
)
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
this.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
}
/**
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
* and 'localized_name' information from the original node definition.
*/
override configure(data: ISerialisedNode): void {
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
// Note: input name is unique in a node definition, so we can lookup
// input by name.
const inputByName = new Map<string, ISerialisableNodeInput>(
data.inputs?.map((input) => [input.name, input]) ?? []
)
// Inputs defined by the node definition.
const definedInputNames = new Set(
this.inputs.map((input) => input.name)
)
const definedInputs = this.inputs.map((input) => {
const inputData = inputByName.get(input.name)
return inputData
? {
...inputData,
// Whether the input has associated widget follows the
// original node definition.
..._.pick(input, RESERVED_KEYS.concat('widget'))
}
: input
})
// Extra inputs that potentially dynamically added by custom js logic.
const extraInputs = data.inputs?.filter(
(input) => !definedInputNames.has(input.name)
)
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
// Note: output name is not unique, so we cannot lookup output by name.
// Use index instead.
data.outputs = _.zip(this.outputs, data.outputs).map(
([output, outputData]) => {
// If there are extra outputs in the serialised node, use them directly.
// There are currently custom nodes that dynamically add outputs via
// js logic.
if (!output) return outputData as ISerialisableNodeOutput
return outputData
? {
...outputData,
..._.pick(output, RESERVED_KEYS)
}
: output
}
)
data.widgets_values = migrateWidgetsValues(
ComfyNode.nodeData.inputs,
this.widgets ?? [],
data.widgets_values ?? []
)
super.configure(data)
}
}
addNodeContextMenuHandler(node)
addDrawBackgroundHandler(node)
addNodeKeyHandler(node)
// Note: Some extensions expects node.comfyClass to be set in
// `beforeRegisterNodeDef`.
node.prototype.comfyClass = nodeDefV1.name
node.comfyClass = nodeDefV1.name
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
node.nodeData = nodeDef
LiteGraph.registerNodeType(subgraph.id, node)
// Note: Do not following assignments before `LiteGraph.registerNodeType`
// because `registerNodeType` will overwrite the assignments.
node.category = nodeDef.category
node.title = nodeDef.display_name || nodeDef.name
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
static comfyClass: string
@@ -622,8 +888,10 @@ export const useLitegraphService = () => {
options
)
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
app.graph.add(node)
graph.add(node)
// @ts-expect-error fixme ts strict error
return node
}
@@ -665,6 +933,7 @@ export const useLitegraphService = () => {
return {
registerNodeDef,
registerSubgraphNodeDef,
addNodeOnGraph,
getCanvasCenter,
goToNode,

View File

@@ -0,0 +1,91 @@
import {
type ExportedSubgraph,
type ExportedSubgraphInstance,
type Subgraph
} from '@comfyorg/litegraph'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useLitegraphService } from './litegraphService'
export const useSubgraphService = () => {
const nodeDefStore = useNodeDefStore()
/** Loads a single subgraph definition and registers it with the node def store */
function registerLitegraphNode(
nodeDef: ComfyNodeDefV1,
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: exportedSubgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
useLitegraphService().registerSubgraphNodeDef(
nodeDef,
subgraph,
instanceData
)
}
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
const { id, name } = exportedSubgraph
const nodeDef: ComfyNodeDefV1 = {
input: { required: {} },
output: [],
output_is_list: [],
output_name: [],
output_tooltips: [],
name: id,
display_name: name,
description: `Subgraph node for ${name}`,
category: 'subgraph',
output_node: false,
python_module: 'nodes'
}
nodeDefStore.addNodeDef(nodeDef)
return nodeDef
}
/** Loads all exported subgraph definitions from workflow */
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
const subgraphs = graphData.definitions?.subgraphs
if (!subgraphs) return
// Assertion: overriding Zod schema
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
const subgraph =
comfyApp.graph.subgraphs.get(subgraphData.id) ??
comfyApp.graph.createSubgraph(subgraphData)
registerNewSubgraph(subgraph, subgraphData)
}
}
/** Registers a new subgraph (e.g. user converted from nodes) */
function registerNewSubgraph(
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const nodeDef = createNodeDef(exportedSubgraph)
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
}
return {
loadSubgraphs,
registerNewSubgraph
}
}

View File

@@ -7,6 +7,7 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
@@ -20,6 +21,7 @@ export const useWorkflowService = () => {
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
@@ -285,11 +287,8 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
}
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
}
/**
@@ -345,8 +344,7 @@ export const useWorkflowService = () => {
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const data = loadedWorkflow.initialState
const workflowJSON = data
const workflowJSON = toRaw(loadedWorkflow.initialState)
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.

View File

@@ -2,7 +2,7 @@
* Stores all DOM widgets that are used in the canvas.
*/
import { defineStore } from 'pinia'
import { type Raw, markRaw, ref } from 'vue'
import { type Raw, computed, markRaw, ref } from 'vue'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { BaseDOMWidget } from '@/scripts/domWidget'
@@ -13,11 +13,20 @@ export interface DomWidgetState extends PositionConfig {
visible: boolean
readonly: boolean
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
}
export const useDomWidgetStore = defineStore('domWidget', () => {
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
const activeWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => state.active)
)
const inactiveWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => !state.active)
)
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
@@ -28,7 +37,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
readonly: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0]
size: [0, 0],
active: true
})
}
@@ -37,9 +47,28 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widgetStates.value.delete(widgetId)
}
const activateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = true
}
const deactivateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = false
}
const clear = () => {
widgetStates.value.clear()
}
return {
widgetStates,
activeWidgetStates,
inactiveWidgetStates,
registerWidget,
unregisterWidget
unregisterWidget,
activateWidget,
deactivateWidget,
clear
}
})

View File

@@ -1,3 +1,4 @@
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -20,9 +21,9 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { ComfyWorkflow } from './workflowStore'
import { useCanvasStore } from './graphStore'
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
export interface QueuedPrompt {
/**
@@ -37,6 +38,9 @@ export interface QueuedPrompt {
}
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const clientId = ref<string | null>(null)
const activePromptId = ref<string | null>(null)
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
@@ -54,12 +58,64 @@ export const useExecutionStore = defineStore('execution', () => {
if (!canvasState) return null
return (
canvasState.nodes.find(
(n: ComfyNode) => String(n.id) === executingNodeId.value
) ?? null
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
)
})
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
/**
* Recursively get the subgraph objects for the given subgraph instance IDs
* @param currentGraph The current graph
* @param subgraphNodeIds The instance IDs
* @param subgraphs The subgraphs
* @returns The subgraphs that correspond to each of the instance IDs.
*/
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const subgraph = workflowStore.activeSubgraph
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(
subgraph.rootGraph,
subgraphNodeIds
)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
// This is the progress of the currently executing node, if any
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
@@ -132,7 +188,7 @@ export const useExecutionStore = defineStore('execution', () => {
activePrompt.value.nodes[e.detail.node] = true
}
function handleExecuting(e: CustomEvent<NodeId | null>) {
function handleExecuting(e: CustomEvent<NodeId | null>): void {
// Clear the current node progress when a new node starts executing
_executingNodeProgress.value = null
@@ -142,12 +198,16 @@ export const useExecutionStore = defineStore('execution', () => {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
}
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
if (typeof e.detail === 'string') {
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
} else {
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
activePromptId.value = null
}
}
@@ -168,19 +228,31 @@ export const useExecutionStore = defineStore('execution', () => {
lastExecutionError.value = e.detail
}
function getNodeIdIfExecuting(nodeId: string | number) {
const nodeIdStr = String(nodeId)
return nodeIdStr.includes(':')
? workflowStore.executionIdToCurrentId(nodeIdStr)
: nodeIdStr
}
function handleProgressText(e: CustomEvent<ProgressTextWsMessage>) {
const { nodeId, text } = e.detail
if (!text || !nodeId) return
const node = app.graph.getNodeById(nodeId)
// Handle hierarchical node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
useNodeProgressText().showTextPreview(node, text)
}
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
const { node_id, component, props = {} } = e.detail
const node = app.graph.getNodeById(node_id)
const { node_id: nodeId, component, props = {} } = e.detail
// Handle hierarchical node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
if (component === 'ChatHistoryWidget') {

View File

@@ -3,7 +3,7 @@ import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
export const useTitleEditorStore = defineStore('titleEditor', () => {
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
@@ -31,6 +31,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
const getCanvas = () => {
if (!canvas.value) throw new Error('getCanvas: canvas is null')
@@ -42,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
selectedItems,
nodeSelected,
groupSelected,
rerouteSelected,
updateSelectedItems,
getCanvas
}

View File

@@ -306,8 +306,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
// Frontend-only nodes don't have nodeDef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Optional chaining used in index
// @ts-expect-error Optional chaining used in index
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
}

View File

@@ -0,0 +1,87 @@
import type { Subgraph } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import { app } from '@/scripts/app'
import { isNonNullish } from '@/utils/typeGuardUtil'
import { useWorkflowStore } from './workflowStore'
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently
* open.
*/
export const useSubgraphNavigationStore = defineStore(
'subgraphNavigation',
() => {
const workflowStore = useWorkflowStore()
/** The currently opened subgraph. */
const activeSubgraph = shallowRef<Subgraph>()
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = shallowReactive<string[]>([])
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
*/
const navigationStack = computed(() =>
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
)
/**
* Restore the navigation stack from a list of subgraph IDs.
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
idStack.length = 0
for (const id of subgraphIds) idStack.push(id)
}
/**
* Export the navigation stack as a list of subgraph IDs.
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack]
// Reset on workflow change
watch(
() => workflowStore.activeWorkflow,
() => (idStack.length = 0)
)
// Update navigation stack when opened subgraph changes
watch(
() => workflowStore.activeSubgraph,
(subgraph) => {
// Navigated back to the root graph
if (!subgraph) {
idStack.length = 0
return
}
const index = idStack.lastIndexOf(subgraph.id)
const lastIndex = idStack.length - 1
if (index === -1) {
// Opened a new subgraph
idStack.push(subgraph.id)
} else if (index !== lastIndex) {
// Navigated to a different subgraph
idStack.splice(index + 1, lastIndex - index)
}
}
)
return {
activeSubgraph,
navigationStack,
restoreState,
exportState
}
}
)

View File

@@ -1,6 +1,7 @@
import type { LGraph, Subgraph } from '@comfyorg/litegraph'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, watch } from 'vue'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
@@ -156,12 +157,12 @@ export interface WorkflowStore {
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
subgraphNamePath: string[]
/** `true` if any subgraph is currently being viewed. */
isSubgraphActive: boolean
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -427,24 +428,61 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/** @see WorkflowStore.subgraphNamePath */
const subgraphNamePath = ref<string[]>([])
/** @see WorkflowStore.isSubgraphActive */
const isSubgraphActive = ref(false)
/** @see WorkflowStore.activeSubgraph */
const activeSubgraph = shallowRef<Raw<Subgraph>>()
/** @see WorkflowStore.updateActiveGraph */
const updateActiveGraph = () => {
const subgraph = comfyApp.canvas?.subgraph
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
if (!comfyApp.canvas) return
const { subgraph } = comfyApp.canvas
isSubgraphActive.value = isSubgraph(subgraph)
}
if (subgraph) {
const [, ...pathFromRoot] = subgraph.pathToRootGraph
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
} else {
subgraphNamePath.value = []
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (subgraph === undefined) throw new Error('Subgraph not found')
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const subgraph = activeSubgraph.value
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// Start from the root graph
const { graph } = comfyApp
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
@@ -473,9 +511,10 @@ export const useWorkflowStore = defineStore('workflow', () => {
getWorkflowByPath,
syncWorkflows,
subgraphNamePath,
isSubgraphActive,
updateActiveGraph
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId
}
}) satisfies () => WorkflowStore

View File

@@ -60,6 +60,7 @@ declare module '@comfyorg/litegraph/dist/types/widgets' {
* ComfyUI extensions of litegraph
*/
declare module '@comfyorg/litegraph' {
import type { ExecutableLGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
interface LGraphNodeConstructor<T extends LGraphNode = LGraphNode> {
@@ -88,7 +89,10 @@ declare module '@comfyorg/litegraph' {
/** @deprecated groupNode */
setInnerNodes?(nodes: LGraphNode[]): void
/** Originally a group node API. */
getInnerNodes?(): LGraphNode[]
getInnerNodes?(
nodes?: ExecutableLGraphNode[],
subgraphs?: WeakSet<LGraphNode>
): ExecutableLGraphNode[]
/** @deprecated groupNode */
convertToNodes?(): LGraphNode[]
recreate?(): Promise<LGraphNode>

View File

@@ -1,5 +1,9 @@
import type { LGraph, NodeId } from '@comfyorg/litegraph'
import { LGraphEventMode } from '@comfyorg/litegraph'
import {
ExecutableNodeDTO,
LGraphEventMode,
SubgraphNode
} from '@comfyorg/litegraph'
import type {
ComfyApiWorkflow,
@@ -74,20 +78,31 @@ export const graphToPrompt = async (
workflow.extra ??= {}
workflow.extra.frontendVersion = __COMFYUI_FRONTEND_VERSION__
const computedNodeDtos = graph
.computeExecutionOrder(false)
.map(
(node) =>
new ExecutableNodeDTO(
node,
[],
node instanceof SubgraphNode ? node : undefined
)
)
let output: ComfyApiWorkflow = {}
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {
const skipNode =
for (const outerNode of computedNodeDtos) {
// Don't serialize muted nodes
if (
outerNode.mode === LGraphEventMode.NEVER ||
outerNode.mode === LGraphEventMode.BYPASS
const innerNodes =
!skipNode && outerNode.getInnerNodes
? outerNode.getInnerNodes()
: [outerNode]
for (const node of innerNodes) {
) {
continue
}
for (const node of outerNode.getInnerNodes()) {
if (
node.isVirtualNode ||
// Don't serialize muted nodes
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
) {
@@ -120,55 +135,14 @@ export const graphToPrompt = async (
// Store all node links
for (const [i, input] of node.inputs.entries()) {
let parent = node.getInputNode(i)
if (!parent) continue
const resolvedInput = node.resolveInput(i)
if (!resolvedInput) continue
let link = node.getInputLink(i)
while (
parent?.mode === LGraphEventMode.BYPASS ||
parent?.isVirtualNode
) {
if (!link) break
if (parent.isVirtualNode) {
link = parent.getInputLink(link.origin_slot)
if (!link) break
parent = parent.getInputNode(link.target_slot)
if (!parent) break
} else if (!parent.inputs) {
// Maintains existing behaviour if parent.getInputLink is overriden
break
} else if (parent.mode === LGraphEventMode.BYPASS) {
// Bypass nodes by finding first input with matching type
const parentInputIndexes = Object.keys(parent.inputs).map(Number)
// Prioritise exact slot index
const indexes = [link.origin_slot].concat(parentInputIndexes)
const matchingIndex = indexes.find(
(index) => parent?.inputs[index]?.type === input.type
)
// No input types match
if (matchingIndex === undefined) break
link = parent.getInputLink(matchingIndex)
if (link) parent = parent.getInputNode(matchingIndex)
}
}
if (link) {
if (parent?.updateLink) {
// Subgraph node / groupNode callback; deprecated, should be replaced
link = parent.updateLink(link)
}
if (link) {
inputs[input.name] = [
String(link.origin_id),
// @ts-expect-error link.origin_slot is already number.
parseInt(link.origin_slot)
]
}
}
inputs[input.name] = [
String(resolvedInput.origin_id),
// @ts-expect-error link.origin_slot is already number.
parseInt(resolvedInput.origin_slot)
]
}
output[String(node.id)] = {

View File

@@ -1,6 +1,10 @@
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
import { ColorOption, LGraph, Reroute } from '@comfyorg/litegraph'
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisation'
import type {
ExportedSubgraph,
ISerialisableNodeInput,
ISerialisedGraph
} from '@comfyorg/litegraph/dist/types/serialisation'
import type {
IBaseWidget,
IComboWidget
@@ -50,6 +54,10 @@ export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
return item instanceof LGraphGroup
}
export const isReroute = (item: unknown): item is Reroute => {
return item instanceof Reroute
}
/**
* Get the color option of all canvas items if they are all the same.
* @param items - The items to get the color option of.
@@ -163,12 +171,11 @@ export function fixLinkInputSlots(graph: LGraph) {
* This should match the serialization format of legacy widget conversion.
*
* @param graph - The graph to compress widget input slots for.
* @throws If an infinite loop is detected.
*/
export function compressWidgetInputSlots(graph: ISerialisedGraph) {
for (const node of graph.nodes) {
node.inputs = node.inputs?.filter(
(input) => !(input.widget && input.link === null)
)
node.inputs = node.inputs?.filter(matchesLegacyApi)
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
@@ -179,4 +186,44 @@ export function compressWidgetInputSlots(graph: ISerialisedGraph) {
}
}
}
compressSubgraphWidgetInputSlots(graph.definitions?.subgraphs)
}
function matchesLegacyApi(input: ISerialisableNodeInput) {
return !(input.widget && input.link === null)
}
/**
* Duplication to handle the legacy link arrays in the root workflow.
* @see compressWidgetInputSlots
* @param subgraph The subgraph to compress widget input slots for.
*/
function compressSubgraphWidgetInputSlots(
subgraphs: ExportedSubgraph[] | undefined,
visited = new WeakSet<ExportedSubgraph>()
) {
if (!subgraphs) return
for (const subgraph of subgraphs) {
if (visited.has(subgraph)) throw new Error('Infinite loop detected')
visited.add(subgraph)
if (subgraph.nodes) {
for (const node of subgraph.nodes) {
node.inputs = node.inputs?.filter(matchesLegacyApi)
if (!subgraph.links) continue
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
const link = subgraph.links.find((link) => link.id === input.link)
if (link) link.target_slot = inputIndex
}
}
}
}
compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited)
}
}

View File

@@ -21,3 +21,9 @@ export const isAbortError = (
export const isSubgraph = (
item: LGraph | Subgraph | undefined | null
): item is Subgraph => item?.isRootGraph === false
/**
* Check if an item is non-nullish.
*/
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
item != null

View File

@@ -492,7 +492,7 @@ describe('useWorkflowStore', () => {
// Assert
console.debug(store.isSubgraphActive)
expect(store.isSubgraphActive).toBe(false) // Should default to false
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
})
it('should correctly update state when the root graph is active', async () => {
@@ -505,7 +505,7 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(false)
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
expect(store.activeSubgraph).toBeUndefined()
})
it('should correctly update state when a subgraph is active', async () => {
@@ -527,10 +527,7 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual([
'Level 1 Subgraph',
'Level 2 Subgraph'
]) // Path excludes the root
expect(store.activeSubgraph).toEqual(mockSubgraph)
})
it('should update automatically when activeWorkflow changes', async () => {
@@ -548,7 +545,7 @@ describe('useWorkflowStore', () => {
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
expect(store.activeSubgraph).toEqual(initialSubgraph)
// Act: Change the active workflow
const workflow2 = store.createTemporary('workflow2.json')
@@ -569,7 +566,7 @@ describe('useWorkflowStore', () => {
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
expect(store.activeSubgraph).toBeUndefined()
})
})
})