custom_node provided blueprints (#6172)

Frontend implementation for a system to allow custom_nodes to provide a
set of subgraph blueprints.

Requires comfyanonymous/ComfyUI#10438, but handles gracefully in unavailable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6172-Early-POC-custom_node-provided-blueprints-2926d73d3650814982ecd43f12abd873)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
AustinMroz
2025-10-24 08:24:34 -07:00
committed by GitHub
parent 8ed9be20a9
commit a108c52572
4 changed files with 77 additions and 23 deletions

View File

@@ -83,11 +83,11 @@ export class ComfyWorkflow extends UserFile {
* @param force Whether to force loading the content even if it is already loaded. * @param force Whether to force loading the content even if it is already loaded.
* @returns this * @returns this
*/ */
override async load({ override async load({ force = false }: { force?: boolean } = {}): Promise<
force = false this & LoadedComfyWorkflow
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> { > {
await super.load({ force }) await super.load({ force })
if (!force && this.isLoaded) return this as LoadedComfyWorkflow if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
if (!this.originalContent) { if (!this.originalContent) {
throw new Error('[ASSERT] Workflow content should be loaded') throw new Error('[ASSERT] Workflow content should be loaded')
@@ -100,7 +100,7 @@ export class ComfyWorkflow extends UserFile {
/* initialState= */ JSON.parse(this.originalContent) /* initialState= */ JSON.parse(this.originalContent)
) )
) )
return this as LoadedComfyWorkflow return this as this & LoadedComfyWorkflow
} }
override unload(): void { override unload(): void {

View File

@@ -204,6 +204,12 @@ type SimpleApiEvents = keyof PickNevers<ApiEventTypes>
/** Keys (names) of API events that pass a {@link CustomEvent} `detail` object. */ /** Keys (names) of API events that pass a {@link CustomEvent} `detail` object. */
type ComplexApiEvents = keyof NeverNever<ApiEventTypes> type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
export type GlobalSubgraphData = {
name: string
info: { node_pack: string }
data: string | Promise<string>
}
function addHeaderEntry(headers: HeadersInit, key: string, value: string) { function addHeaderEntry(headers: HeadersInit, key: string, value: string) {
if (Array.isArray(headers)) { if (Array.isArray(headers)) {
headers.push([key, value]) headers.push([key, value])
@@ -1118,6 +1124,22 @@ export class ComfyApi extends EventTarget {
return resp.json() return resp.json()
} }
async getGlobalSubgraphData(id: string): Promise<string> {
const resp = await api.fetchApi('/global_subgraphs/' + id)
if (resp.status !== 200) return ''
const subgraph: GlobalSubgraphData = await resp.json()
return subgraph?.data ?? ''
}
async getGlobalSubgraphs(): Promise<Record<string, GlobalSubgraphData>> {
const resp = await api.fetchApi('/global_subgraphs')
if (resp.status !== 200) return {}
const subgraphs: Record<string, GlobalSubgraphData> = await resp.json()
for (const [k, v] of Object.entries(subgraphs)) {
if (!v.data) v.data = this.getGlobalSubgraphData(k)
}
return subgraphs
}
async getLogs(): Promise<string> { async getLogs(): Promise<string> {
return (await axios.get(this.internalURL('/logs'))).data return (await axios.get(this.internalURL('/logs'))).data
} }

View File

@@ -775,7 +775,8 @@ export class ComfyApp {
this.canvasElRef.value = canvasEl this.canvasElRef.value = canvasEl
await useWorkspaceStore().workflow.syncWorkflows() await useWorkspaceStore().workflow.syncWorkflows()
await useSubgraphStore().fetchSubgraphs() //Doesn't need to block. Blueprints will load async
void useSubgraphStore().fetchSubgraphs()
await useExtensionService().loadExtensions() await useExtensionService().loadExtensions()
this.addProcessKeyHandler() this.addProcessKeyHandler()

View File

@@ -23,6 +23,7 @@ import type {
InputSpec InputSpec
} from '@/schemas/nodeDefSchema' } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import type { GlobalSubgraphData } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -106,9 +107,9 @@ export const useSubgraphStore = defineStore('subgraph', () => {
useSubgraphStore().updateDef(await this.load()) useSubgraphStore().updateDef(await this.load())
return ret return ret
} }
override async load({ override async load({ force = false }: { force?: boolean } = {}): Promise<
force = false this & LoadedComfyWorkflow
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> { > {
if (!force && this.isLoaded) return await super.load({ force }) if (!force && this.isLoaded) return await super.load({ force })
const loaded = await super.load({ force }) const loaded = await super.load({ force })
const st = loaded.activeState const st = loaded.activeState
@@ -147,20 +148,46 @@ export const useSubgraphStore = defineStore('subgraph', () => {
modified: number modified: number
size: number size: number
}): Promise<void> { }): Promise<void> {
const name = options.path.slice(0, -'.json'.length)
options.path = SubgraphBlueprint.basePath + options.path options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load() const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp) useWorkflowStore().attachWorkflow(bp)
const nodeDef = convertToNodeDef(bp) registerNodeDef(bp)
}
subgraphDefCache.value.set(name, nodeDef) async function loadInstalledBlueprints() {
subgraphCache[name] = bp async function loadGlobalBlueprint([k, v]: [string, GlobalSubgraphData]) {
const path = SubgraphBlueprint.basePath + v.name + '.json'
const blueprint = new SubgraphBlueprint({
path,
modified: Date.now(),
size: -1
})
blueprint.originalContent = blueprint.content = await v.data
blueprint.filename = v.name
useWorkflowStore().attachWorkflow(blueprint)
const loaded = await blueprint.load()
registerNodeDef(
loaded,
{
python_module: v.info.node_pack,
display_name: v.name
},
k
)
}
const subgraphs = await api.getGlobalSubgraphs()
await Promise.allSettled(
Object.entries(subgraphs).map(loadGlobalBlueprint)
)
} }
const res = ( const userSubs = (
await api.listUserDataFullInfo(SubgraphBlueprint.basePath) await api.listUserDataFullInfo(SubgraphBlueprint.basePath)
).filter((f) => f.path.endsWith('.json')) ).filter((f) => f.path.endsWith('.json'))
const settled = await Promise.allSettled(res.map(loadBlueprint)) const settled = await Promise.allSettled([
...userSubs.map(loadBlueprint),
loadInstalledBlueprints()
])
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason) const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e)) errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
if (errors.length > 0) { if (errors.length > 0) {
@@ -172,8 +199,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}) })
} }
} }
function convertToNodeDef(workflow: LoadedComfyWorkflow): ComfyNodeDefImpl { function registerNodeDef(
const name = workflow.filename workflow: LoadedComfyWorkflow,
overrides: Partial<ComfyNodeDefV1> = {},
name: string = workflow.filename
) {
const subgraphNode = workflow.changeTracker.initialState.nodes[0] const subgraphNode = workflow.changeTracker.initialState.nodes[0]
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint') if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
subgraphNode.inputs ??= [] subgraphNode.inputs ??= []
@@ -197,10 +227,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
description, description,
category: 'Subgraph Blueprints', category: 'Subgraph Blueprints',
output_node: false, output_node: false,
python_module: 'blueprint' python_module: 'blueprint',
...overrides
} }
const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1) const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1)
return nodeDefImpl subgraphDefCache.value.set(name, nodeDefImpl)
subgraphCache[name] = workflow
} }
async function publishSubgraph() { async function publishSubgraph() {
const canvas = canvasStore.getCanvas() const canvas = canvasStore.getCanvas()
@@ -252,8 +284,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
await workflow.save() await workflow.save()
//add to files list? //add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow) useWorkflowStore().attachWorkflow(loadedWorkflow)
subgraphDefCache.value.set(name, convertToNodeDef(loadedWorkflow)) registerNodeDef(loadedWorkflow)
subgraphCache[name] = loadedWorkflow
useToastStore().add({ useToastStore().add({
severity: 'success', severity: 'success',
summary: t('subgraphStore.publishSuccess'), summary: t('subgraphStore.publishSuccess'),
@@ -262,7 +293,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
}) })
} }
function updateDef(blueprint: LoadedComfyWorkflow) { function updateDef(blueprint: LoadedComfyWorkflow) {
subgraphDefCache.value.set(blueprint.filename, convertToNodeDef(blueprint)) registerNodeDef(blueprint)
} }
async function editBlueprint(nodeType: string) { async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length) const name = nodeType.slice(typePrefix.length)