Partial execute to selected output nodes (#3818)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2025-05-08 11:44:26 -04:00
committed by GitHub
parent b6466c44e5
commit 2425e32d51
21 changed files with 351 additions and 12 deletions

View File

@@ -0,0 +1,85 @@
{
"id": "1a95532f-c8aa-4c9d-a7f6-f928ba2d4862",
"revision": 0,
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 4,
"type": "PreviewAny",
"pos": [946.2566528320312, 598.4373168945312],
"size": [140, 76],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": []
},
{
"id": 1,
"type": "PreviewAny",
"pos": [951.0236206054688, 421.3861083984375],
"size": [140, 76],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": []
},
{
"id": 3,
"type": "PrimitiveString",
"pos": [575.1760864257812, 504.5214538574219],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [2, 3]
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": ["foo"]
}
],
"links": [
[2, 3, 0, 1, 0, "*"],
[3, 3, 0, 4, 0, "*"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.19.1",
"ds": {
"offset": [400, 400],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -18,3 +18,27 @@ test.describe('Execution', () => {
)
})
})
test.describe('Execute to selected output nodes', () => {
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution/partial_execution')
const input = await comfyPage.getNodeRefById(3)
const output1 = await comfyPage.getNodeRefById(1)
const output2 = await comfyPage.getNodeRefById(4)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
await output1.click('title')
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
// @note: Wait for the execution to finish. We might want to move to a more
// reliable way to wait for the execution to finish. Workflow in this test
// is simple enough that this is fine for now.
await comfyPage.page.waitForTimeout(200)
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
expect(await (await output2.getWidget(0)).getValue()).toBe('')
})
})

View File

@@ -6,6 +6,7 @@
content: 'p-0 flex flex-row'
}"
>
<ExecuteButton v-show="nodeSelected" />
<ColorPickerButton v-show="nodeSelected || groupSelected" />
<Button
v-show="nodeSelected"
@@ -74,6 +75,7 @@ import Panel from 'primevue/panel'
import { computed } from 'vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
import { st, t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'

View File

@@ -0,0 +1,71 @@
<template>
<Button
v-tooltip.top="{
value: isDisabled
? t('selectionToolbox.executeButton.disabledTooltip')
: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
:severity="isDisabled ? 'secondary' : 'success'"
text
:disabled="isDisabled"
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"
@click="handleClick"
>
<i-lucide:play />
</Button>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const commandStore = useCommandStore()
const canvas = canvasStore.getCanvas()
const buttonHovered = ref(false)
const selectedOutputNodes = computed(
() =>
canvasStore.selectedItems.filter(
(item) => isLGraphNode(item) && item.constructor.nodeData.output_node
) as LGraphNode[]
)
const isDisabled = computed(() => selectedOutputNodes.value.length === 0)
function outputNodeStokeStyle(this: LGraphNode) {
if (
this.selected &&
this.constructor.nodeData.output_node &&
buttonHovered.value
) {
return { color: 'orange', lineWidth: 2, padding: 10 }
}
}
const handleMouseEnter = () => {
buttonHovered.value = true
for (const node of selectedOutputNodes.value) {
node.strokeStyles['outputNode'] = outputNodeStokeStyle
}
canvas.setDirty(true)
}
const handleMouseLeave = () => {
buttonHovered.value = false
canvas.setDirty(true)
}
const handleClick = async () => {
await commandStore.execute('Comfy.QueueSelectedOutputNodes')
}
</script>

View File

@@ -312,6 +312,28 @@ export function useCoreCommands(): ComfyCommand[] {
await app.queuePrompt(-1, batchCount)
}
},
{
id: 'Comfy.QueueSelectedOutputNodes',
icon: 'pi pi-play',
label: 'Queue Selected Output Nodes',
versionAdded: '1.19.6',
function: async () => {
const batchCount = useQueueSettingsStore().batchCount
const queueNodeIds = getSelectedNodes()
.filter((node) => node.constructor.nodeData.output_node)
.map((node) => node.id)
if (queueNodeIds.length === 0) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
detail: t('toastMessages.pleaseSelectOutputNodes'),
life: 3000
})
return
}
await app.queuePrompt(0, batchCount, queueNodeIds)
}
},
{
id: 'Comfy.ShowSettingsDialog',
icon: 'pi pi-cog',

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "Queue Prompt (Front)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "Queue Selected Output Nodes"
},
"Comfy_Redo": {
"label": "Redo"
},

View File

@@ -803,6 +803,7 @@
"Open": "Open",
"Queue Prompt": "Queue Prompt",
"Queue Prompt (Front)": "Queue Prompt (Front)",
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
"Redo": "Redo",
"Refresh Node Definitions": "Refresh Node Definitions",
"Save": "Save",
@@ -1197,6 +1198,8 @@
"resizeNodeMatchOutput": "Resize Node to match output"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",
"no3dScene": "No 3D scene to apply texture",
"failedToApplyTexture": "Failed to apply texture",
"no3dSceneToExport": "No 3D scene to export",
@@ -1343,5 +1346,11 @@
"title": "Run!"
}
}
},
"selectionToolbox": {
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
"disabledTooltip": "No output nodes selected"
}
}
}

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "Prompt de Cola (Frente)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "Encolar nodos de salida seleccionados"
},
"Comfy_Redo": {
"label": "Rehacer"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
"Queue Prompt": "Indicador de cola",
"Queue Prompt (Front)": "Indicador de cola (Frente)",
"Queue Selected Output Nodes": "Encolar nodos de salida seleccionados",
"Quit": "Salir",
"Redo": "Rehacer",
"Refresh Node Definitions": "Actualizar definiciones de nodo",
@@ -801,6 +802,12 @@
},
"title": "Tu dispositivo no es compatible"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "No hay nodos de salida seleccionados",
"tooltip": "Ejecutar en los nodos de salida seleccionados (resaltados con borde naranja)"
}
},
"serverConfig": {
"modifiedConfigs": "Has modificado las siguientes configuraciones del servidor. Reinicia para aplicar los cambios.",
"restart": "Reiniciar",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "No hay plantillas para exportar",
"nodeDefinitionsUpdated": "Definiciones de nodos actualizadas",
"nothingToGroup": "Nada para agrupar",
"nothingToQueue": "Nada para poner en cola",
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"updateRequested": "Actualización solicitada",

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "Invite de file d'attente (avant)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "Mettre en file dattente les nœuds de sortie sélectionnés"
},
"Comfy_Redo": {
"label": "Refaire"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "Flux de travail ouvert précédent",
"Queue Prompt": "Invite de file d'attente",
"Queue Prompt (Front)": "Invite de file d'attente (Front)",
"Queue Selected Output Nodes": "Mettre en file dattente les nœuds de sortie sélectionnés",
"Quit": "Quitter",
"Redo": "Refaire",
"Refresh Node Definitions": "Actualiser les définitions de nœud",
@@ -801,6 +802,12 @@
},
"title": "Votre appareil n'est pas pris en charge"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Aucun nœud de sortie sélectionné",
"tooltip": "Exécuter vers les nœuds de sortie sélectionnés (surlignés avec une bordure orange)"
}
},
"serverConfig": {
"modifiedConfigs": "Vous avez modifié les configurations suivantes du serveur. Redémarrez pour appliquer les modifications.",
"restart": "Redémarrer",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "Aucun modèle à exporter",
"nodeDefinitionsUpdated": "Définitions de nœuds mises à jour",
"nothingToGroup": "Rien à regrouper",
"nothingToQueue": "Rien à ajouter à la file dattente",
"pendingTasksDeleted": "Tâches en attente supprimées",
"pleaseSelectNodesToGroup": "Veuillez sélectionner les nœuds (ou autres groupes) pour créer un groupe pour",
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
"updateRequested": "Mise à jour demandée",

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "キュープロンプト(フロント)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "選択した出力ノードをキューに追加"
},
"Comfy_Redo": {
"label": "やり直す"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "前に開いたワークフロー",
"Queue Prompt": "キューのプロンプト",
"Queue Prompt (Front)": "キューのプロンプト (前面)",
"Queue Selected Output Nodes": "選択した出力ノードをキューに追加",
"Quit": "終了",
"Redo": "やり直す",
"Refresh Node Definitions": "ノード定義を更新",
@@ -801,6 +802,12 @@
},
"title": "お使いのデバイスはサポートされていません"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "出力ノードが選択されていません",
"tooltip": "選択した出力ノードに実行(オレンジ色の枠でハイライト表示)"
}
},
"serverConfig": {
"modifiedConfigs": "以下のサーバー設定を変更しました。変更を適用するには再起動してください。",
"restart": "再起動",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "エクスポートするテンプレートがありません",
"nodeDefinitionsUpdated": "ノード定義が更新されました",
"nothingToGroup": "グループ化するものがありません",
"nothingToQueue": "キューに追加する項目がありません",
"pendingTasksDeleted": "保留中のタスクが削除されました",
"pleaseSelectNodesToGroup": "グループを作成するためのノード(または他のグループ)を選択してください",
"pleaseSelectOutputNodes": "出力ノードを選択してください",
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
"updateRequested": "更新が要求されました",

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "실행 큐 맨 앞에 프롬프트 추가"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "선택한 출력 노드 대기열에 추가"
},
"Comfy_Redo": {
"label": "다시 실행"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "이전 열린 워크플로",
"Queue Prompt": "실행 큐에 프롬프트 추가",
"Queue Prompt (Front)": "실행 큐 맨 앞에 프롬프트 추가",
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
"Quit": "종료",
"Redo": "다시 실행",
"Refresh Node Definitions": "노드 정의 새로 고침",
@@ -801,6 +802,12 @@
},
"title": "이 장치는 지원되지 않습니다."
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "선택된 출력 노드가 없습니다",
"tooltip": "선택한 출력 노드에 실행 (주황색 테두리로 강조 표시됨)"
}
},
"serverConfig": {
"modifiedConfigs": "다음 서버 구성을 수정했습니다. 변경 사항을 적용하려면 다시 시작하세오.",
"restart": "다시 시작",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "내보낼 템플릿이 없습니다",
"nodeDefinitionsUpdated": "노드 정의가 업데이트되었습니다",
"nothingToGroup": "그룹화할 항목이 없습니다",
"nothingToQueue": "대기열에 추가할 항목이 없습니다",
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
"pleaseSelectNodesToGroup": "그룹을 만들기 위해 노드(또는 다른 그룹)를 선택해 주세요",
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
"updateRequested": "업데이트 요청됨",

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "Очередь запросов (передняя)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "Добавить выбранные выходные узлы в очередь"
},
"Comfy_Redo": {
"label": "Повторить"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "Предыдущий открытый рабочий процесс",
"Queue Prompt": "Запрос в очереди",
"Queue Prompt (Front)": "Запрос в очереди (спереди)",
"Queue Selected Output Nodes": "Добавить выбранные выходные узлы в очередь",
"Quit": "Выйти",
"Redo": "Повторить",
"Refresh Node Definitions": "Обновить определения нод",
@@ -801,6 +802,12 @@
},
"title": "Ваше устройство не поддерживается"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Выходные узлы не выбраны",
"tooltip": "Выполнить для выбранных выходных узлов (выделены оранжевой рамкой)"
}
},
"serverConfig": {
"modifiedConfigs": "Вы изменили следующие конфигурации сервера. Перезапустите, чтобы применить изменения.",
"restart": "Перезапустить",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "Нет шаблонов для экспорта",
"nodeDefinitionsUpdated": "Определения узлов обновлены",
"nothingToGroup": "Нечего группировать",
"nothingToQueue": "Нет заданий в очереди",
"pendingTasksDeleted": "Ожидающие задачи удалены",
"pleaseSelectNodesToGroup": "Пожалуйста, выберите узлы (или другие группы) для создания группы",
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
"updateRequested": "Запрошено обновление",

View File

@@ -155,6 +155,9 @@
"Comfy_QueuePromptFront": {
"label": "执行提示词(前端)"
},
"Comfy_QueueSelectedOutputNodes": {
"label": "队列所选输出节点"
},
"Comfy_Redo": {
"label": "重做"
},

View File

@@ -695,6 +695,7 @@
"Previous Opened Workflow": "上一个打开的工作流",
"Queue Prompt": "执行提示词",
"Queue Prompt (Front)": "执行提示词 (优先执行)",
"Queue Selected Output Nodes": "将所选输出节点加入队列",
"Quit": "退出",
"Redo": "重做",
"Refresh Node Definitions": "刷新节点定义",
@@ -801,6 +802,12 @@
},
"title": "您的设备不受支持"
},
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "未选择输出节点",
"tooltip": "执行到选定的输出节点(用橙色边框高亮显示)"
}
},
"serverConfig": {
"modifiedConfigs": "您已修改以下服务器配置。重启以应用更改。",
"restart": "重启",
@@ -1297,8 +1304,10 @@
"noTemplatesToExport": "没有模板可以导出",
"nodeDefinitionsUpdated": "节点定义已更新",
"nothingToGroup": "没有可分组的内容",
"nothingToQueue": "没有可加入队列的内容",
"pendingTasksDeleted": "待处理任务已删除",
"pleaseSelectNodesToGroup": "请选取节点(或其他组)以创建分组",
"pleaseSelectOutputNodes": "请选择输出节点",
"unableToGetModelFilePath": "无法获取模型文件路径",
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
"updateRequested": "已请求更新",

View File

@@ -107,7 +107,11 @@ export class ComfyApp {
/**
* List of entries to queue
*/
#queueItems: { number: number; batchCount: number }[] = []
#queueItems: {
number: number
batchCount: number
queueNodeIds?: NodeId[]
}[] = []
/**
* If the queue is currently being processed
*/
@@ -1144,14 +1148,22 @@ export class ComfyApp {
})
}
async graphToPrompt(graph = this.graph) {
async graphToPrompt(
graph = this.graph,
options: { queueNodeIds?: NodeId[] } = {}
) {
return graphToPrompt(graph, {
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave'),
queueNodeIds: options.queueNodeIds
})
}
async queuePrompt(number: number, batchCount: number = 1): Promise<boolean> {
this.#queueItems.push({ number, batchCount })
async queuePrompt(
number: number,
batchCount: number = 1,
queueNodeIds?: NodeId[]
): Promise<boolean> {
this.#queueItems.push({ number, batchCount, queueNodeIds })
// Only have one action process the items so each one gets a unique seed correctly
if (this.#processingQueue) {
@@ -1167,14 +1179,14 @@ export class ComfyApp {
try {
while (this.#queueItems.length) {
const { number, batchCount } = this.#queueItems.pop()!
const { number, batchCount, queueNodeIds } = this.#queueItems.pop()!
for (let i = 0; i < batchCount; i++) {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
const p = await this.graphToPrompt()
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
api.authToken = comfyOrgAuthToken
const res = await api.queuePrompt(number, p)

View File

@@ -1,4 +1,4 @@
import type { LGraph } from '@comfyorg/litegraph'
import type { LGraph, NodeId } from '@comfyorg/litegraph'
import { LGraphEventMode } from '@comfyorg/litegraph'
import type {
@@ -8,16 +8,46 @@ import type {
import { compressWidgetInputSlots } from './litegraphUtil'
/**
* Recursively target node's parent nodes to the new output.
* @param nodeId The node id to add.
* @param oldOutput The old output.
* @param newOutput The new output.
* @returns The new output.
*/
function recursiveAddNodes(
nodeId: NodeId,
oldOutput: ComfyApiWorkflow,
newOutput: ComfyApiWorkflow
) {
const currentId = String(nodeId)
const currentNode = oldOutput[currentId]!
if (newOutput[currentId] == null) {
newOutput[currentId] = currentNode
for (const inputValue of Object.values(currentNode.inputs || [])) {
if (Array.isArray(inputValue)) {
recursiveAddNodes(inputValue[0], oldOutput, newOutput)
}
}
}
return newOutput
}
/**
* Converts the current graph workflow for sending to the API.
* Note: Node widgets are updated before serialization to prepare queueing.
* @note Node widgets are updated before serialization to prepare queueing.
*
* @param graph The graph to convert.
* @param options The options for the conversion.
* - `sortNodes`: Whether to sort the nodes by execution order.
* - `queueNodeIds`: The output nodes to execute. Execute all output nodes if not provided.
* @returns The workflow and node links
*/
export const graphToPrompt = async (
graph: LGraph,
options: { sortNodes?: boolean } = {}
options: { sortNodes?: boolean; queueNodeIds?: NodeId[] } = {}
): Promise<{ workflow: ComfyWorkflowJSON; output: ComfyApiWorkflow }> => {
const { sortNodes = false } = options
const { sortNodes = false, queueNodeIds } = options
for (const node of graph.computeExecutionOrder(false)) {
const innerNodes = node.getInnerNodes ? node.getInnerNodes() : [node]
@@ -44,7 +74,7 @@ export const graphToPrompt = async (
workflow.extra ??= {}
workflow.extra.frontendVersion = __COMFYUI_FRONTEND_VERSION__
const output: ComfyApiWorkflow = {}
let output: ComfyApiWorkflow = {}
// Process nodes in order of execution
for (const outerNode of graph.computeExecutionOrder(false)) {
const skipNode =
@@ -163,6 +193,15 @@ export const graphToPrompt = async (
}
}
// Partial execution
if (queueNodeIds?.length) {
const newOutput = {}
for (const queueNodeId of queueNodeIds) {
recursiveAddNodes(queueNodeId, output, newOutput)
}
output = newOutput
}
// @ts-expect-error Convert ISerializedGraph to ComfyWorkflowJSON
return { workflow: workflow as ComfyWorkflowJSON, output }
}