mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 11:41:34 +00:00
Compare commits
1 Commits
feat/hub-t
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56da748234 |
@@ -58,7 +58,9 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Public API shim for custom nodes, consumed by comfyAPIPlugin at build time
|
||||
'src/scripts/nodeEvents.ts'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -18,12 +19,9 @@ useExtensionService().registerExtension({
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 350)])
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: ImageCompareOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const { a_images: aImages, b_images: bImages } = output
|
||||
node.on(NodeEvent.EXECUTED, ({ output }) => {
|
||||
const { a_images: aImages, b_images: bImages } =
|
||||
output as ImageCompareOutput
|
||||
const rand = app.getRandParam()
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
@@ -42,6 +40,6 @@ useExtensionService().registerExtension({
|
||||
widget.value = { beforeImages, afterImages }
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -475,8 +476,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
@@ -502,10 +501,8 @@ useExtensionService().registerExtension({
|
||||
config.configure(settings)
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Load3dPreviewOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
node.on(NodeEvent.EXECUTED, ({ output }) => {
|
||||
const result = (output as Load3dPreviewOutput).result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
@@ -533,7 +530,7 @@ useExtensionService().registerExtension({
|
||||
if (bgImagePath) {
|
||||
load3d.setBackgroundImage(bgImagePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeOutputWith, ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
@@ -81,12 +82,8 @@ useExtensionService().registerExtension({
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
node.onExecuted = function (output: SaveMeshOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const fileInfo = output['3d']?.[0]
|
||||
node.on(NodeEvent.EXECUTED, ({ output }) => {
|
||||
const fileInfo = (output as SaveMeshOutput)['3d']?.[0]
|
||||
|
||||
if (!fileInfo) return
|
||||
|
||||
@@ -119,6 +116,6 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph, NodeEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
@@ -315,6 +315,12 @@ export class PrimitiveNode extends LGraphNode {
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||
this.emit(NodeEvent.WIDGET_CHANGED, {
|
||||
name: widget.name,
|
||||
value: newValue,
|
||||
oldValue,
|
||||
widget
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode } from './LGraphNode'
|
||||
import type { NodeId } from './LGraphNode'
|
||||
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
|
||||
import { LLink } from './LLink'
|
||||
import type { LinkId, SerialisedLLinkArray } from './LLink'
|
||||
import { MapProxyHandler } from './MapProxyHandler'
|
||||
@@ -394,6 +395,8 @@ export class LGraph
|
||||
if (this._nodes) {
|
||||
for (const _node of this._nodes) {
|
||||
_node.onRemoved?.()
|
||||
_node.emit?.(NodeEvent.REMOVED)
|
||||
_node._removeAllListeners?.()
|
||||
this.onNodeRemoved?.(_node)
|
||||
}
|
||||
}
|
||||
@@ -1004,6 +1007,7 @@ export class LGraph
|
||||
this._nodes_by_id[node.id] = node
|
||||
|
||||
node.onAdded?.(this)
|
||||
node.emit?.(NodeEvent.ADDED, { graph: this })
|
||||
|
||||
if (this.config.align_to_grid) node.alignToGrid()
|
||||
|
||||
@@ -1100,6 +1104,8 @@ export class LGraph
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.emit?.(NodeEvent.REMOVED)
|
||||
innerNode._removeAllListeners?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
@@ -1108,6 +1114,8 @@ export class LGraph
|
||||
|
||||
// callback
|
||||
node.onRemoved?.()
|
||||
node.emit?.(NodeEvent.REMOVED)
|
||||
node._removeAllListeners?.()
|
||||
|
||||
node.graph = null
|
||||
this._version++
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { LGraph } from './LGraph'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode } from './LGraphNode'
|
||||
import type { NodeId, NodeProperty } from './LGraphNode'
|
||||
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
|
||||
import { LLink } from './LLink'
|
||||
import type { LinkId } from './LLink'
|
||||
import { Reroute } from './Reroute'
|
||||
@@ -3080,6 +3081,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// value changed
|
||||
if (oldValue != widget.value) {
|
||||
node.onWidgetChanged?.(widget.name, widget.value, oldValue, widget)
|
||||
node.emit(NodeEvent.WIDGET_CHANGED, {
|
||||
name: widget.name,
|
||||
value: widget.value,
|
||||
oldValue,
|
||||
widget
|
||||
})
|
||||
if (!node.graph) throw new NullGraphError()
|
||||
node.graph._version++
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ import type {
|
||||
TWidgetType,
|
||||
TWidgetValue
|
||||
} from './types/widgets'
|
||||
import type { INodeEventEmitter } from './infrastructure/NodeEventEmitter'
|
||||
import { applyNodeEventEmitter } from './infrastructure/NodeEventEmitter'
|
||||
import type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
|
||||
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
|
||||
import { findFreeSlotOfType } from './utils/collections'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { distributeSpace } from './utils/spaceDistribution'
|
||||
@@ -229,9 +233,15 @@ export interface LGraphNode {
|
||||
* @param type a type for the node
|
||||
*/
|
||||
|
||||
export interface LGraphNode extends INodeEventEmitter<LGraphNodeEventMap> {}
|
||||
|
||||
export class LGraphNode
|
||||
implements NodeLike, Positionable, IPinnable, IColorable
|
||||
{
|
||||
/** @internal Lazily-created instance event listener map. */
|
||||
declare _eventListeners?: Map<string, Set<(detail: unknown) => void>>
|
||||
/** @internal Class-level event listener map (static). */
|
||||
static _classEventListeners?: Map<string, Set<(detail: unknown) => void>>
|
||||
// Static properties used by dynamic child classes
|
||||
static title?: string
|
||||
static MAX_CONSOLE?: number
|
||||
@@ -877,6 +887,13 @@ export class LGraphNode
|
||||
? this.graph._links.get(input.link)
|
||||
: null
|
||||
this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.INPUT,
|
||||
index: i,
|
||||
isConnected: true,
|
||||
link,
|
||||
inputOrOutput: input
|
||||
})
|
||||
this.onInputAdded?.(input)
|
||||
}
|
||||
|
||||
@@ -890,6 +907,13 @@ export class LGraphNode
|
||||
for (const linkId of output.links) {
|
||||
const link = this.graph ? this.graph._links.get(linkId) : null
|
||||
this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.OUTPUT,
|
||||
index: i,
|
||||
isConnected: true,
|
||||
link,
|
||||
inputOrOutput: output
|
||||
})
|
||||
}
|
||||
this.onOutputAdded?.(output)
|
||||
}
|
||||
@@ -935,6 +959,7 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
this.onConfigure?.(info)
|
||||
this.emit(NodeEvent.CONFIGURED, { serialisedNode: info })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1594,6 +1619,7 @@ export class LGraphNode
|
||||
setSize(size: Size): void {
|
||||
this.size = size
|
||||
this.onResize?.(this.size)
|
||||
this.emit(NodeEvent.RESIZE, { size: this.size })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2996,6 +3022,13 @@ export class LGraphNode
|
||||
link,
|
||||
output
|
||||
)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.OUTPUT,
|
||||
index: outputIndex,
|
||||
isConnected: true,
|
||||
link,
|
||||
inputOrOutput: output
|
||||
})
|
||||
|
||||
inputNode.onConnectionsChange?.(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -3004,6 +3037,13 @@ export class LGraphNode
|
||||
link,
|
||||
input
|
||||
)
|
||||
inputNode.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.INPUT,
|
||||
index: inputIndex,
|
||||
isConnected: true,
|
||||
link,
|
||||
inputOrOutput: input
|
||||
})
|
||||
|
||||
this.setDirtyCanvas(false, true)
|
||||
graph.afterChange()
|
||||
@@ -3145,6 +3185,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.INPUT,
|
||||
index: link_info.target_slot,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: input
|
||||
})
|
||||
this.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
slot,
|
||||
@@ -3152,6 +3199,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.OUTPUT,
|
||||
index: slot,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: output
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
@@ -3197,6 +3251,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
target.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.INPUT,
|
||||
index: link_info.target_slot,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: input
|
||||
})
|
||||
}
|
||||
// remove the link from the links pool
|
||||
link_info.disconnect(graph, 'input')
|
||||
@@ -3208,6 +3269,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.OUTPUT,
|
||||
index: slot,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: output
|
||||
})
|
||||
}
|
||||
output.links = null
|
||||
}
|
||||
@@ -3310,6 +3378,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
input
|
||||
)
|
||||
this.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.INPUT,
|
||||
index: slot,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: input
|
||||
})
|
||||
target_node.onConnectionsChange?.(
|
||||
NodeSlotType.OUTPUT,
|
||||
i,
|
||||
@@ -3317,6 +3392,13 @@ export class LGraphNode
|
||||
link_info,
|
||||
output
|
||||
)
|
||||
target_node.emit(NodeEvent.CONNECTIONS_CHANGE, {
|
||||
type: NodeSlotType.OUTPUT,
|
||||
index: i,
|
||||
isConnected: false,
|
||||
link: link_info,
|
||||
inputOrOutput: output
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4283,3 +4365,5 @@ export class LGraphNode
|
||||
ctx.fillStyle = originalFillStyle
|
||||
}
|
||||
}
|
||||
|
||||
applyNodeEventEmitter(LGraphNode)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LGraph } from './LGraph'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode } from './LGraphNode'
|
||||
import { NodeEvent } from './infrastructure/LGraphNodeEventMap'
|
||||
import { LLink } from './LLink'
|
||||
import { Reroute } from './Reroute'
|
||||
import { InputIndicators } from './canvas/InputIndicators'
|
||||
@@ -571,6 +572,7 @@ export class LiteGraphGlobal {
|
||||
|
||||
// callback
|
||||
node.onNodeCreated?.()
|
||||
node.emit?.(NodeEvent.NODE_CREATED)
|
||||
return node
|
||||
}
|
||||
|
||||
|
||||
43
src/lib/litegraph/src/infrastructure/LGraphNodeEventMap.ts
Normal file
43
src/lib/litegraph/src/infrastructure/LGraphNodeEventMap.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
Size
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export const NodeEvent = {
|
||||
NODE_CREATED: 'node-created',
|
||||
CONFIGURED: 'configured',
|
||||
CONNECTIONS_CHANGE: 'connections-change',
|
||||
WIDGET_CHANGED: 'widget-changed',
|
||||
ADDED: 'added',
|
||||
REMOVED: 'removed',
|
||||
RESIZE: 'resize',
|
||||
EXECUTED: 'executed'
|
||||
} as const
|
||||
|
||||
export type LGraphNodeEventMap = {
|
||||
[NodeEvent.NODE_CREATED]: never
|
||||
[NodeEvent.CONFIGURED]: { serialisedNode: ISerialisedNode }
|
||||
[NodeEvent.CONNECTIONS_CHANGE]: {
|
||||
type: ISlotType
|
||||
index: number
|
||||
isConnected: boolean
|
||||
link: LLink | null | undefined
|
||||
inputOrOutput: INodeInputSlot | INodeOutputSlot
|
||||
}
|
||||
[NodeEvent.WIDGET_CHANGED]: {
|
||||
name: string
|
||||
value: unknown
|
||||
oldValue: unknown
|
||||
widget: IBaseWidget
|
||||
}
|
||||
[NodeEvent.ADDED]: { graph: LGraph }
|
||||
[NodeEvent.REMOVED]: never
|
||||
[NodeEvent.RESIZE]: { size: Size }
|
||||
[NodeEvent.EXECUTED]: { output: Record<string, unknown> }
|
||||
}
|
||||
399
src/lib/litegraph/src/infrastructure/NodeEventEmitter.test.ts
Normal file
399
src/lib/litegraph/src/infrastructure/NodeEventEmitter.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
NodeEvent,
|
||||
onAllNodeEvents,
|
||||
offAllNodeEvents
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
function createGraph(): LGraph {
|
||||
return new LGraph()
|
||||
}
|
||||
|
||||
function createConnectedNodes(graph: LGraph) {
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'number')
|
||||
const target = new LGraphNode('Target')
|
||||
target.addInput('in', 'number')
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
return { source, target }
|
||||
}
|
||||
|
||||
describe('NodeEventEmitter', () => {
|
||||
let origLiteGraph: typeof LiteGraph
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
origLiteGraph = Object.assign({}, LiteGraph)
|
||||
// @ts-expect-error Intended: Force remove an otherwise readonly non-optional property
|
||||
delete origLiteGraph.Classes
|
||||
|
||||
Object.assign(LiteGraph, {
|
||||
NODE_TITLE_HEIGHT: 20,
|
||||
NODE_SLOT_HEIGHT: 15,
|
||||
NODE_TEXT_SIZE: 14,
|
||||
DEFAULT_SHADOW_COLOR: 'rgba(0,0,0,0.5)',
|
||||
DEFAULT_GROUP_FONT_SIZE: 24,
|
||||
isValidConnection: vi.fn().mockReturnValue(true)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(LiteGraph, origLiteGraph)
|
||||
})
|
||||
|
||||
describe('instance-level on/off/emit', () => {
|
||||
test('fires listener when event is emitted', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.NODE_CREATED, listener)
|
||||
node.emit(NodeEvent.NODE_CREATED)
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('passes detail payload to listener', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.EXECUTED, listener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: { text: ['hello'] } })
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({ output: { text: ['hello'] } })
|
||||
})
|
||||
|
||||
test('supports multiple listeners on the same event', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
node.on(NodeEvent.EXECUTED, listener1)
|
||||
node.on(NodeEvent.EXECUTED, listener2)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener1).toHaveBeenCalledOnce()
|
||||
expect(listener2).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('removes listener with off()', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.EXECUTED, listener)
|
||||
node.off(NodeEvent.EXECUTED, listener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('on() returns an unsubscribe function', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
const unsub = node.on(NodeEvent.EXECUTED, listener)
|
||||
unsub()
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does not fire listeners for other event types', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.EXECUTED, listener)
|
||||
node.emit(NodeEvent.NODE_CREATED)
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('isolates listeners between node instances', () => {
|
||||
const node1 = new LGraphNode('A')
|
||||
const node2 = new LGraphNode('B')
|
||||
const listener = vi.fn()
|
||||
|
||||
node1.on(NodeEvent.EXECUTED, listener)
|
||||
node2.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('once()', () => {
|
||||
test('fires listener only once', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.once(NodeEvent.EXECUTED, listener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: { a: 1 } })
|
||||
node.emit(NodeEvent.EXECUTED, { output: { a: 2 } })
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener).toHaveBeenCalledWith({ output: { a: 1 } })
|
||||
})
|
||||
|
||||
test('returns an unsubscribe function that cancels before firing', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
const unsub = node.once(NodeEvent.EXECUTED, listener)
|
||||
unsub()
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does not interfere with other listeners', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const onceListener = vi.fn()
|
||||
const permanentListener = vi.fn()
|
||||
|
||||
node.once(NodeEvent.EXECUTED, onceListener)
|
||||
node.on(NodeEvent.EXECUTED, permanentListener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(onceListener).toHaveBeenCalledOnce()
|
||||
expect(permanentListener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('class-level onAllNodeEvents', () => {
|
||||
afterEach(() => {
|
||||
LGraphNode._classEventListeners?.clear()
|
||||
})
|
||||
|
||||
test('fires class-level listener on any instance emit', () => {
|
||||
const listener = vi.fn()
|
||||
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
|
||||
|
||||
const node = new LGraphNode('Test')
|
||||
node.emit(NodeEvent.EXECUTED, { output: { data: 1 } })
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({ output: { data: 1 } })
|
||||
})
|
||||
|
||||
test('fires class-level listener with node as this', () => {
|
||||
let capturedThis: unknown
|
||||
const listener = function (this: unknown) {
|
||||
capturedThis = this
|
||||
}
|
||||
onAllNodeEvents(LGraphNode, NodeEvent.NODE_CREATED, listener)
|
||||
|
||||
const node = new LGraphNode('Test')
|
||||
node.emit(NodeEvent.NODE_CREATED)
|
||||
|
||||
expect(capturedThis).toBe(node)
|
||||
})
|
||||
|
||||
test('fires both instance and class listeners', () => {
|
||||
const instanceListener = vi.fn()
|
||||
const classListener = vi.fn()
|
||||
|
||||
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, classListener)
|
||||
const node = new LGraphNode('Test')
|
||||
node.on(NodeEvent.EXECUTED, instanceListener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(instanceListener).toHaveBeenCalledOnce()
|
||||
expect(classListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('removes class-level listener with offAllNodeEvents', () => {
|
||||
const listener = vi.fn()
|
||||
onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
|
||||
offAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
|
||||
|
||||
const node = new LGraphNode('Test')
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('onAllNodeEvents returns an unsubscribe function', () => {
|
||||
const listener = vi.fn()
|
||||
const unsub = onAllNodeEvents(LGraphNode, NodeEvent.EXECUTED, listener)
|
||||
unsub()
|
||||
|
||||
const node = new LGraphNode('Test')
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error isolation', () => {
|
||||
test('continues calling listeners if one throws', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const errorListener = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const normalListener = vi.fn()
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
node.on(NodeEvent.EXECUTED, errorListener)
|
||||
node.on(NodeEvent.EXECUTED, normalListener)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(errorListener).toHaveBeenCalledOnce()
|
||||
expect(normalListener).toHaveBeenCalledOnce()
|
||||
expect(console.error).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('automatic cleanup on node removal', () => {
|
||||
test('clears all listeners when node is removed from graph', () => {
|
||||
const graph = createGraph()
|
||||
const node = new LGraphNode('Test')
|
||||
graph.add(node)
|
||||
|
||||
const listener = vi.fn()
|
||||
node.on(NodeEvent.EXECUTED, listener)
|
||||
|
||||
graph.remove(node)
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears listeners on graph.clear()', () => {
|
||||
const graph = createGraph()
|
||||
const node = new LGraphNode('Test')
|
||||
graph.add(node)
|
||||
|
||||
const listener = vi.fn()
|
||||
node.on(NodeEvent.EXECUTED, listener)
|
||||
|
||||
graph.clear()
|
||||
node.emit(NodeEvent.EXECUTED, { output: {} })
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle event integration', () => {
|
||||
test('emits added when node is added to graph', () => {
|
||||
const graph = createGraph()
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.ADDED, listener)
|
||||
graph.add(node)
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener).toHaveBeenCalledWith({ graph })
|
||||
})
|
||||
|
||||
test('emits removed when node is removed from graph', () => {
|
||||
const graph = createGraph()
|
||||
const node = new LGraphNode('Test')
|
||||
graph.add(node)
|
||||
|
||||
const listener = vi.fn()
|
||||
node.on(NodeEvent.REMOVED, listener)
|
||||
graph.remove(node)
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('emits configured after node.configure()', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const graph = createGraph()
|
||||
graph.add(node)
|
||||
|
||||
const listener = vi.fn()
|
||||
node.on(NodeEvent.CONFIGURED, listener)
|
||||
|
||||
const serialised: ISerialisedNode = {
|
||||
id: node.id,
|
||||
type: 'Test',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
node.configure(serialised)
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
serialisedNode: serialised
|
||||
})
|
||||
})
|
||||
|
||||
test('emits resize when setSize is called', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const listener = vi.fn()
|
||||
|
||||
node.on(NodeEvent.RESIZE, listener)
|
||||
node.setSize([200, 300])
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
const detail = listener.mock.calls[0][0]
|
||||
expect(detail.size[0]).toBe(200)
|
||||
expect(detail.size[1]).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connections-change event', () => {
|
||||
test('emits on both nodes when connected', () => {
|
||||
const graph = createGraph()
|
||||
const { source, target } = createConnectedNodes(graph)
|
||||
const sourceListener = vi.fn()
|
||||
const targetListener = vi.fn()
|
||||
|
||||
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
|
||||
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
|
||||
expect(sourceListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 2, // NodeSlotType.OUTPUT
|
||||
index: 0,
|
||||
isConnected: true
|
||||
})
|
||||
)
|
||||
expect(targetListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 1, // NodeSlotType.INPUT
|
||||
index: 0,
|
||||
isConnected: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('emits on both nodes when disconnected', () => {
|
||||
const graph = createGraph()
|
||||
const { source, target } = createConnectedNodes(graph)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const sourceListener = vi.fn()
|
||||
const targetListener = vi.fn()
|
||||
source.on(NodeEvent.CONNECTIONS_CHANGE, sourceListener)
|
||||
target.on(NodeEvent.CONNECTIONS_CHANGE, targetListener)
|
||||
|
||||
target.disconnectInput(0)
|
||||
|
||||
expect(sourceListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isConnected: false
|
||||
})
|
||||
)
|
||||
expect(targetListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isConnected: false
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
143
src/lib/litegraph/src/infrastructure/NodeEventEmitter.ts
Normal file
143
src/lib/litegraph/src/infrastructure/NodeEventEmitter.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
type Listener<T = unknown> = (detail: T) => void
|
||||
|
||||
type ListenerMap = Map<string, Set<Listener>>
|
||||
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
interface NodeWithEvents {
|
||||
_eventListeners?: ListenerMap
|
||||
constructor: { _classEventListeners?: ListenerMap }
|
||||
}
|
||||
|
||||
type EventMapConstraint = { [K: string]: unknown }
|
||||
|
||||
export interface INodeEventEmitter<EventMap extends EventMapConstraint> {
|
||||
on<K extends keyof EventMap & string>(
|
||||
type: K,
|
||||
listener: Listener<EventMap[K]>
|
||||
): Unsubscribe
|
||||
|
||||
once<K extends keyof EventMap & string>(
|
||||
type: K,
|
||||
listener: Listener<EventMap[K]>
|
||||
): Unsubscribe
|
||||
|
||||
off<K extends keyof EventMap & string>(
|
||||
type: K,
|
||||
listener: Listener<EventMap[K]>
|
||||
): void
|
||||
|
||||
emit<K extends keyof EventMap & string>(
|
||||
type: K,
|
||||
...args: EventMap[K] extends never ? [] : [detail: EventMap[K]]
|
||||
): void
|
||||
|
||||
_removeAllListeners?(): void
|
||||
}
|
||||
|
||||
function addListener(
|
||||
node: NodeWithEvents,
|
||||
type: string,
|
||||
listener: Listener
|
||||
): Unsubscribe {
|
||||
if (!node._eventListeners) node._eventListeners = new Map()
|
||||
let listeners = node._eventListeners.get(type)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
node._eventListeners.set(type, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => node._eventListeners?.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
function nodeOn(
|
||||
this: NodeWithEvents,
|
||||
type: string,
|
||||
listener: Listener
|
||||
): Unsubscribe {
|
||||
return addListener(this, type, listener)
|
||||
}
|
||||
|
||||
function nodeOnce(
|
||||
this: NodeWithEvents,
|
||||
type: string,
|
||||
listener: Listener
|
||||
): Unsubscribe {
|
||||
const wrapper: Listener = (detail) => {
|
||||
this._eventListeners?.get(type)?.delete(wrapper)
|
||||
listener(detail)
|
||||
}
|
||||
return addListener(this, type, wrapper)
|
||||
}
|
||||
|
||||
function nodeOff(this: NodeWithEvents, type: string, listener: Listener) {
|
||||
this._eventListeners?.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
function nodeEmit(this: NodeWithEvents, type: string, detail?: unknown) {
|
||||
const listeners = this._eventListeners?.get(type)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(detail)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ComfyUI] Error in node event listener for "${type}":`,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const classListeners = this.constructor._classEventListeners?.get(type)
|
||||
if (classListeners) {
|
||||
for (const listener of classListeners) {
|
||||
try {
|
||||
listener.call(this, detail)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[ComfyUI] Error in class event listener for "${type}":`,
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRemoveAllListeners(this: NodeWithEvents) {
|
||||
this._eventListeners?.clear()
|
||||
}
|
||||
|
||||
export function applyNodeEventEmitter(target: { prototype: object }): void {
|
||||
Object.assign(target.prototype, {
|
||||
on: nodeOn,
|
||||
once: nodeOnce,
|
||||
off: nodeOff,
|
||||
emit: nodeEmit,
|
||||
_removeAllListeners: nodeRemoveAllListeners
|
||||
})
|
||||
}
|
||||
|
||||
export function onAllNodeEvents(
|
||||
nodeClass: { _classEventListeners?: ListenerMap },
|
||||
type: string,
|
||||
listener: Listener
|
||||
): Unsubscribe {
|
||||
if (!nodeClass._classEventListeners)
|
||||
nodeClass._classEventListeners = new Map()
|
||||
let listeners = nodeClass._classEventListeners.get(type)
|
||||
if (!listeners) {
|
||||
listeners = new Set()
|
||||
nodeClass._classEventListeners.set(type, listeners)
|
||||
}
|
||||
listeners.add(listener)
|
||||
return () => nodeClass._classEventListeners?.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
export function offAllNodeEvents(
|
||||
nodeClass: { _classEventListeners?: ListenerMap },
|
||||
type: string,
|
||||
listener: Listener
|
||||
): void {
|
||||
nodeClass._classEventListeners?.get(type)?.delete(listener)
|
||||
}
|
||||
@@ -86,6 +86,13 @@ export { ContextMenu } from './ContextMenu'
|
||||
|
||||
export { DragAndScale } from './DragAndScale'
|
||||
|
||||
export { NodeEvent } from './infrastructure/LGraphNodeEventMap'
|
||||
export type { LGraphNodeEventMap } from './infrastructure/LGraphNodeEventMap'
|
||||
export {
|
||||
onAllNodeEvents,
|
||||
offAllNodeEvents
|
||||
} from './infrastructure/NodeEventEmitter'
|
||||
export type { Unsubscribe } from './infrastructure/NodeEventEmitter'
|
||||
export { Rectangle } from './infrastructure/Rectangle'
|
||||
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
|
||||
export type {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Size
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
@@ -436,6 +437,12 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
this.callback?.(this.value, canvas, node, pos, e)
|
||||
|
||||
node.onWidgetChanged?.(this.name ?? '', v, oldValue, this)
|
||||
node.emit(NodeEvent.WIDGET_CHANGED, {
|
||||
name: this.name ?? '',
|
||||
value: v,
|
||||
oldValue,
|
||||
widget: this
|
||||
})
|
||||
if (node.graph) node.graph._version++
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
|
||||
import {
|
||||
mapHubWorkflowIndexEntryToTemplate,
|
||||
mapHubWorkflowIndexToCategories
|
||||
} from './hubWorkflowIndexMapper'
|
||||
|
||||
const makeEntry = (
|
||||
overrides?: Partial<HubWorkflowIndexEntry>
|
||||
): HubWorkflowIndexEntry => ({
|
||||
name: 'sdxl_simple',
|
||||
title: 'SDXL Simple',
|
||||
status: 'approved',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('mapHubWorkflowIndexEntryToTemplate', () => {
|
||||
it('maps template metadata used by the selector dialog', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
description: 'Starter SDXL workflow',
|
||||
tags: ['Image', 'Text to Image'],
|
||||
models: ['SDXL'],
|
||||
requiresCustomNodes: ['comfy-custom-pack'],
|
||||
thumbnailVariant: 'compareSlider',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
size: 1024,
|
||||
vram: 2048,
|
||||
openSource: true,
|
||||
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl',
|
||||
logos: [
|
||||
{
|
||||
provider: 'OpenAI',
|
||||
label: 'OpenAI',
|
||||
opacity: 0.7
|
||||
}
|
||||
],
|
||||
date: '2026-04-14',
|
||||
includeOnDistributions: ['cloud', 'desktop', 'unsupported'],
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
|
||||
shareId: 'share-123',
|
||||
usage: 42,
|
||||
searchRank: 7,
|
||||
isEssential: true,
|
||||
useCase: 'Image generation',
|
||||
license: 'MIT'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'sdxl_simple',
|
||||
title: 'SDXL Simple',
|
||||
description: 'Starter SDXL workflow',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
thumbnailVariant: 'compareSlider',
|
||||
isEssential: true,
|
||||
shareId: 'share-123',
|
||||
tags: ['Image', 'Text to Image'],
|
||||
models: ['SDXL'],
|
||||
date: '2026-04-14',
|
||||
useCase: 'Image generation',
|
||||
license: 'MIT',
|
||||
vram: 2048,
|
||||
size: 1024,
|
||||
openSource: true,
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
|
||||
requiresCustomNodes: ['comfy-custom-pack'],
|
||||
searchRank: 7,
|
||||
usage: 42,
|
||||
includeOnDistributions: ['cloud', 'desktop'],
|
||||
logos: [{ provider: 'OpenAI', label: 'OpenAI', opacity: 0.7 }],
|
||||
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl'
|
||||
})
|
||||
})
|
||||
|
||||
it('infers video thumbnails from preview URLs', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
thumbnailUrl: 'https://cdn.example.com/preview.mp4'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.mediaType).toBe('video')
|
||||
expect(result.mediaSubtype).toBe('mp4')
|
||||
})
|
||||
|
||||
it('drops invalid logo and distribution values', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
logos: [
|
||||
{ provider: ['OpenAI', 'Runway'], gap: -4 },
|
||||
{ provider: 123 }
|
||||
] as Array<Record<string, unknown>>,
|
||||
includeOnDistributions: ['local', 'desktop', 'invalid']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.logos).toEqual([{ provider: ['OpenAI', 'Runway'], gap: -4 }])
|
||||
expect(result.includeOnDistributions).toEqual(['local', 'desktop'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapHubWorkflowIndexToCategories', () => {
|
||||
it('wraps flat hub entries in a single default category', () => {
|
||||
const result = mapHubWorkflowIndexToCategories(
|
||||
[
|
||||
makeEntry({ name: 'template-a', title: 'Template A' }),
|
||||
makeEntry({ name: 'template-b', title: 'Template B' })
|
||||
],
|
||||
'All Templates'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].moduleName).toBe('default')
|
||||
expect(result[0].title).toBe('All Templates')
|
||||
expect(result[0].templates.map((template) => template.name)).toEqual([
|
||||
'template-a',
|
||||
'template-b'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
import { TemplateIncludeOnDistributionEnum } from '../types/template'
|
||||
import type {
|
||||
LogoInfo,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '../types/template'
|
||||
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
|
||||
|
||||
const distributionValues = new Set(
|
||||
Object.values(TemplateIncludeOnDistributionEnum)
|
||||
)
|
||||
|
||||
function getPreviewExtension(url?: string): string | undefined {
|
||||
if (!url) return undefined
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(url)
|
||||
const extension = pathname.split('.').pop()?.toLowerCase()
|
||||
return extension || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewMediaType(
|
||||
thumbnailUrl?: string,
|
||||
mediaType?: string
|
||||
): string | undefined {
|
||||
const extension = getPreviewExtension(thumbnailUrl)
|
||||
|
||||
if (extension && ['mp4', 'webm', 'mov'].includes(extension)) {
|
||||
return 'video'
|
||||
}
|
||||
|
||||
if (extension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
return mediaType
|
||||
}
|
||||
|
||||
function getPreviewMediaSubtype(
|
||||
thumbnailUrl?: string,
|
||||
mediaSubtype?: string
|
||||
): string {
|
||||
return getPreviewExtension(thumbnailUrl) ?? mediaSubtype ?? 'webp'
|
||||
}
|
||||
|
||||
function mapLogo(logo: Record<string, unknown>): LogoInfo | null {
|
||||
const provider = logo.provider
|
||||
|
||||
if (
|
||||
typeof provider !== 'string' &&
|
||||
!(
|
||||
Array.isArray(provider) &&
|
||||
provider.length > 0 &&
|
||||
provider.every((value) => typeof value === 'string')
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
...(typeof logo.label === 'string' ? { label: logo.label } : {}),
|
||||
...(typeof logo.gap === 'number' ? { gap: logo.gap } : {}),
|
||||
...(typeof logo.position === 'string' ? { position: logo.position } : {}),
|
||||
...(typeof logo.opacity === 'number' ? { opacity: logo.opacity } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function mapLogos(
|
||||
logos?: Array<Record<string, unknown>>
|
||||
): LogoInfo[] | undefined {
|
||||
const mapped = logos?.map(mapLogo).filter((logo): logo is LogoInfo => !!logo)
|
||||
return mapped?.length ? mapped : undefined
|
||||
}
|
||||
|
||||
function mapIncludeOnDistributions(
|
||||
includeOnDistributions?: string[]
|
||||
): TemplateIncludeOnDistributionEnum[] | undefined {
|
||||
const mapped = includeOnDistributions?.filter(
|
||||
(value): value is TemplateIncludeOnDistributionEnum =>
|
||||
distributionValues.has(value as TemplateIncludeOnDistributionEnum)
|
||||
)
|
||||
return mapped?.length ? mapped : undefined
|
||||
}
|
||||
|
||||
export function mapHubWorkflowIndexEntryToTemplate(
|
||||
entry: HubWorkflowIndexEntry
|
||||
): TemplateInfo {
|
||||
return {
|
||||
name: entry.name,
|
||||
title: entry.title,
|
||||
description: entry.description ?? '',
|
||||
mediaType:
|
||||
getPreviewMediaType(entry.thumbnailUrl, entry.mediaType) ?? 'image',
|
||||
mediaSubtype: getPreviewMediaSubtype(
|
||||
entry.thumbnailUrl,
|
||||
entry.mediaSubtype
|
||||
),
|
||||
thumbnailVariant: entry.thumbnailVariant,
|
||||
isEssential: entry.isEssential,
|
||||
shareId: entry.shareId,
|
||||
tags: entry.tags,
|
||||
models: entry.models,
|
||||
date: entry.date,
|
||||
useCase: entry.useCase,
|
||||
license: entry.license,
|
||||
vram: entry.vram,
|
||||
size: entry.size,
|
||||
openSource: entry.openSource,
|
||||
thumbnailUrl: entry.thumbnailUrl,
|
||||
thumbnailComparisonUrl: entry.thumbnailComparisonUrl,
|
||||
tutorialUrl: entry.tutorialUrl,
|
||||
requiresCustomNodes: entry.requiresCustomNodes,
|
||||
searchRank: entry.searchRank,
|
||||
usage: entry.usage,
|
||||
includeOnDistributions: mapIncludeOnDistributions(
|
||||
entry.includeOnDistributions
|
||||
),
|
||||
logos: mapLogos(entry.logos)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapHubWorkflowIndexToCategories(
|
||||
entries: HubWorkflowIndexEntry[],
|
||||
title: string = 'All'
|
||||
): WorkflowTemplates[] {
|
||||
return [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title,
|
||||
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,15 +17,6 @@ const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn()
|
||||
}))
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const workflowTemplateStoreMocks = vi.hoisted(() => ({
|
||||
getTemplateByName: vi.fn(),
|
||||
getTemplateByShareId: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
let mockQueryParams: Record<string, string | string[] | undefined> = {}
|
||||
const mockRouterReplace = vi.fn()
|
||||
@@ -44,19 +35,6 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => workflowTemplateStoreMocks
|
||||
})
|
||||
)
|
||||
|
||||
// Mock template workflows composable
|
||||
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
|
||||
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
|
||||
@@ -107,7 +85,6 @@ describe('useTemplateUrlLoader', () => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockCanvasStore.linearMode = false
|
||||
distributionState.isCloud = false
|
||||
})
|
||||
|
||||
it('does not load template when no query param present', () => {
|
||||
@@ -265,21 +242,6 @@ describe('useTemplateUrlLoader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves cloud template URLs by share id before loading', async () => {
|
||||
distributionState.isCloud = true
|
||||
mockQueryParams = { template: 'share-123' }
|
||||
workflowTemplateStoreMocks.getTemplateByName.mockReturnValue(undefined)
|
||||
workflowTemplateStoreMocks.getTemplateByShareId.mockReturnValue({
|
||||
name: 'sdxl_simple',
|
||||
sourceModule: 'hub'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith('sdxl_simple', 'hub')
|
||||
})
|
||||
|
||||
it('removes template params from URL after successful load', async () => {
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
|
||||
@@ -2,11 +2,9 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -110,24 +108,9 @@ export function useTemplateUrlLoader() {
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
let resolvedTemplate = templateParam
|
||||
let resolvedSource = sourceParam
|
||||
|
||||
if (isCloud) {
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const matchedTemplate =
|
||||
workflowTemplatesStore.getTemplateByName(templateParam) ??
|
||||
workflowTemplatesStore.getTemplateByShareId(templateParam)
|
||||
|
||||
if (matchedTemplate) {
|
||||
resolvedTemplate = matchedTemplate.name
|
||||
resolvedSource = matchedTemplate.sourceModule ?? resolvedSource
|
||||
}
|
||||
}
|
||||
|
||||
const success = await templateWorkflows.loadWorkflowTemplate(
|
||||
resolvedTemplate,
|
||||
resolvedSource
|
||||
templateParam,
|
||||
sourceParam
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
|
||||
@@ -15,16 +15,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -59,14 +49,6 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGetSharedWorkflow = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getSharedWorkflow: mockGetSharedWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
@@ -76,20 +58,9 @@ describe('useTemplateWorkflows', () => {
|
||||
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
|
||||
|
||||
beforeEach(() => {
|
||||
distributionState.isCloud = false
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
getTemplateByName: vi.fn((name: string) =>
|
||||
name === 'template1'
|
||||
? {
|
||||
name: 'template1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Template 1 description'
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
groupedTemplates: [
|
||||
{
|
||||
label: 'ComfyUI Examples',
|
||||
@@ -144,16 +115,6 @@ describe('useTemplateWorkflows', () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ workflow: 'data' })
|
||||
} as Partial<Response> as Response)
|
||||
|
||||
mockGetSharedWorkflow.mockResolvedValue({
|
||||
shareId: 'share-123',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'Shared Template',
|
||||
listed: true,
|
||||
publishedAt: null,
|
||||
workflowJson: { workflow: 'shared' },
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
|
||||
it('should load templates from store', async () => {
|
||||
@@ -217,25 +178,6 @@ describe('useTemplateWorkflows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefer absolute thumbnail URLs when provided', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'hub-template',
|
||||
mediaSubtype: 'webp',
|
||||
mediaType: 'image',
|
||||
description: 'Hub template',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp'
|
||||
}
|
||||
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '1')).toBe(
|
||||
'https://cdn.example.com/thumb.webp'
|
||||
)
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '2')).toBe(
|
||||
'https://cdn.example.com/compare.webp'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format template titles correctly', () => {
|
||||
const { getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
@@ -365,27 +307,4 @@ describe('useTemplateWorkflows', () => {
|
||||
// Restore console.error
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should load cloud templates by share id through the shared workflow service', async () => {
|
||||
distributionState.isCloud = true
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
vi.mocked(fetch).mockClear()
|
||||
mockWorkflowTemplatesStore.getTemplateByName = vi.fn(() => ({
|
||||
name: 'template1',
|
||||
shareId: 'share-123',
|
||||
sourceModule: 'default',
|
||||
title: 'Template 1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Template 1 description'
|
||||
}))
|
||||
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
const result = await loadWorkflowTemplate('template1', 'hub')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('share-123')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -17,7 +16,6 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
@@ -66,14 +64,6 @@ export function useTemplateWorkflows() {
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
if (template.thumbnailUrl) {
|
||||
if (index === '2' && template.thumbnailComparisonUrl) {
|
||||
return template.thumbnailComparisonUrl
|
||||
}
|
||||
|
||||
return template.thumbnailUrl
|
||||
}
|
||||
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
@@ -167,15 +157,6 @@ export function useTemplateWorkflows() {
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
const template = workflowTemplatesStore.getTemplateByName(id)
|
||||
|
||||
if (isCloud && template?.shareId) {
|
||||
const workflow = await workflowShareService.getSharedWorkflow(
|
||||
template.shareId
|
||||
)
|
||||
return workflow.workflowJson
|
||||
}
|
||||
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useWorkflowTemplatesStore } from './workflowTemplatesStore'
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getWorkflowTemplates: vi.fn(),
|
||||
getHubWorkflowTemplateIndex: vi.fn(),
|
||||
getCoreWorkflowTemplates: vi.fn(),
|
||||
fileURL: vi.fn((path: string) => path)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
locale: ref('en')
|
||||
}
|
||||
},
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
describe('workflowTemplatesStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
distributionState.isCloud = true
|
||||
|
||||
apiMocks.getWorkflowTemplates.mockResolvedValue({})
|
||||
apiMocks.getHubWorkflowTemplateIndex.mockResolvedValue([
|
||||
{
|
||||
name: 'starter-template',
|
||||
title: 'Starter Template',
|
||||
status: 'approved',
|
||||
description: 'A cloud starter workflow',
|
||||
shareId: 'share-123',
|
||||
usage: 10,
|
||||
searchRank: 5,
|
||||
isEssential: true,
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp'
|
||||
}
|
||||
])
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
headers: {
|
||||
get: vi.fn(() => 'application/json')
|
||||
},
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('loads cloud templates from the hub index and resolves share ids', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
const template = store.getTemplateByShareId('share-123')
|
||||
expect(template?.name).toBe('starter-template')
|
||||
expect(template?.shareId).toBe('share-123')
|
||||
expect(store.knownTemplateNames.has('starter-template')).toBe(true)
|
||||
})
|
||||
|
||||
it('creates a generic getting started nav item for essential cloud templates', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
const navItem = store.navGroupedTemplates.find(
|
||||
(item) => 'id' in item && item.id === 'basics-getting-started'
|
||||
)
|
||||
|
||||
expect(navItem).toEqual({
|
||||
id: 'basics-getting-started',
|
||||
label: 'Getting Started',
|
||||
icon: expect.any(String)
|
||||
})
|
||||
expect(
|
||||
store
|
||||
.filterTemplatesByCategory('basics-getting-started')
|
||||
.map((template) => template.name)
|
||||
).toEqual(['starter-template'])
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { mapHubWorkflowIndexToCategories } from '../adapters/hubWorkflowIndexMapper'
|
||||
import { zLogoIndex } from '../schemas/templateSchema'
|
||||
import type { LogoIndex } from '../schemas/templateSchema'
|
||||
import type {
|
||||
@@ -42,14 +41,6 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
return enhancedTemplates.value.find((template) => template.name === name)
|
||||
}
|
||||
|
||||
const getTemplateByShareId = (
|
||||
shareId: string
|
||||
): EnhancedTemplate | undefined => {
|
||||
return enhancedTemplates.value.find(
|
||||
(template) => template.shareId === shareId
|
||||
)
|
||||
}
|
||||
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
@@ -213,7 +204,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
category: category.title,
|
||||
categoryType: category.type,
|
||||
categoryGroup: category.category,
|
||||
isEssential: template.isEssential ?? category.isEssential,
|
||||
isEssential: category.isEssential,
|
||||
isPartnerNode: template.openSource === false,
|
||||
searchableText: [
|
||||
template.title || template.name,
|
||||
@@ -270,16 +261,12 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
|
||||
if (categoryId.startsWith('basics-')) {
|
||||
const basicsCategory = categoryId.replace('basics-', '')
|
||||
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) =>
|
||||
t.isEssential &&
|
||||
(t.category?.toLowerCase().replace(/\s+/g, '-') ===
|
||||
basicsCategory ||
|
||||
(basicsCategory === 'getting-started' &&
|
||||
(!t.category || t.sourceModule === 'default')))
|
||||
t.category?.toLowerCase().replace(/\s+/g, '-') ===
|
||||
categoryId.replace('basics-', '')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,17 +355,6 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
getCategoryIcon(essentialCat.type || 'getting-started')
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
enhancedTemplates.value.some((template) => template.isEssential)
|
||||
) {
|
||||
items.push({
|
||||
id: generateCategoryId('basics', 'Getting Started'),
|
||||
label: st(
|
||||
'templateWorkflows.category.Getting Started',
|
||||
'Getting Started'
|
||||
),
|
||||
icon: getCategoryIcon('getting-started')
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Group categories from JSON dynamically
|
||||
@@ -497,31 +473,10 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
})
|
||||
|
||||
async function fetchCoreTemplates() {
|
||||
if (isCloud) {
|
||||
const [hubIndexResult, logoIndexResult] = await Promise.all([
|
||||
api.getHubWorkflowTemplateIndex(),
|
||||
fetchLogoIndex()
|
||||
])
|
||||
|
||||
coreTemplates.value = mapHubWorkflowIndexToCategories(
|
||||
hubIndexResult,
|
||||
st('templateWorkflows.category.All', 'All')
|
||||
)
|
||||
englishTemplates.value = []
|
||||
logoIndex.value = logoIndexResult
|
||||
|
||||
const coreNames = coreTemplates.value.flatMap((category) =>
|
||||
category.templates.map((template) => template.name)
|
||||
)
|
||||
const customNames = Object.values(customTemplates.value).flat()
|
||||
knownTemplateNames.value = new Set([...coreNames, ...customNames])
|
||||
return
|
||||
}
|
||||
|
||||
const locale = i18n.global.locale.value
|
||||
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
|
||||
api.getCoreWorkflowTemplates(locale),
|
||||
locale !== 'en'
|
||||
isCloud && locale !== 'en'
|
||||
? api.getCoreWorkflowTemplates('en')
|
||||
: Promise.resolve([]),
|
||||
fetchLogoIndex()
|
||||
@@ -628,7 +583,6 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName,
|
||||
getTemplateByShareId,
|
||||
getEnglishMetadata,
|
||||
getLogoUrl
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { zHubWorkflowTemplateEntry } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// The live cloud index response currently includes fields that are not yet
|
||||
// present in the generated ingest OpenAPI types.
|
||||
export const zHubWorkflowIndexEntry = zHubWorkflowTemplateEntry.extend({
|
||||
usage: z.number().optional(),
|
||||
searchRank: z.number().optional(),
|
||||
isEssential: z.boolean().optional(),
|
||||
useCase: z.string().optional(),
|
||||
license: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowIndexResponse = z.array(zHubWorkflowIndexEntry)
|
||||
|
||||
export type HubWorkflowIndexEntry = z.infer<typeof zHubWorkflowIndexEntry>
|
||||
@@ -26,7 +26,6 @@ export interface TemplateInfo {
|
||||
localizedDescription?: string
|
||||
isEssential?: boolean
|
||||
sourceModule?: string
|
||||
shareId?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
@@ -41,8 +40,6 @@ export interface TemplateInfo {
|
||||
* Whether this template uses open source models. When false, indicates partner/API node templates.
|
||||
*/
|
||||
openSource?: boolean
|
||||
thumbnailUrl?: string
|
||||
thumbnailComparisonUrl?: string
|
||||
/**
|
||||
* Array of custom node package IDs required for this template (from Custom Node Registry).
|
||||
* Templates with this field will be hidden on local installations temporarily.
|
||||
|
||||
@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import type {
|
||||
@@ -50,6 +51,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type {
|
||||
FilterOption,
|
||||
OwnershipFilterOption,
|
||||
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -133,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -195,6 +198,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -257,6 +261,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { isComboWidget, NodeEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
@@ -137,6 +137,12 @@ function createAssetBrowserWidget(
|
||||
defaultValue,
|
||||
onValueChange: (widget, newValue, oldValue) => {
|
||||
node.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||
node.emit(NodeEvent.WIDGET_CHANGED, {
|
||||
name: widget.name,
|
||||
value: newValue,
|
||||
oldValue,
|
||||
widget
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { zHubWorkflowIndexResponse } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
|
||||
import type { HubWorkflowIndexEntry } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON,
|
||||
@@ -829,21 +827,6 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
async getHubWorkflowTemplateIndex(): Promise<HubWorkflowIndexEntry[]> {
|
||||
const res = await this.fetchApi('/hub/workflows/index')
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load hub workflow index: ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowIndexResponse.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow index response')
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of embedding names
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
LiteGraph,
|
||||
NodeEvent
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -738,8 +739,9 @@ export class ComfyApp {
|
||||
})
|
||||
|
||||
const node = getNodeByExecutionId(this.rootGraph, executionId)
|
||||
if (node && node.onExecuted) {
|
||||
node.onExecuted(detail.output)
|
||||
if (node) {
|
||||
node.onExecuted?.(detail.output)
|
||||
node.emit(NodeEvent.EXECUTED, { output: detail.output })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
19
src/scripts/nodeEvents.ts
Normal file
19
src/scripts/nodeEvents.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Public API for node event subscriptions.
|
||||
*
|
||||
* Exposed via window.comfyAPI.nodeEvents for custom node extensions.
|
||||
*
|
||||
* Usage:
|
||||
* const { NodeEvent, onAllNodeEvents, offAllNodeEvents } = window.comfyAPI.nodeEvents
|
||||
*
|
||||
* // In beforeRegisterNodeDef:
|
||||
* onAllNodeEvents(nodeType, NodeEvent.EXECUTED, function(detail) {
|
||||
* // `this` is the node instance
|
||||
* console.log('Node executed:', detail.output)
|
||||
* })
|
||||
*/
|
||||
export { NodeEvent } from '@/lib/litegraph/src/infrastructure/LGraphNodeEventMap'
|
||||
export {
|
||||
onAllNodeEvents,
|
||||
offAllNodeEvents
|
||||
} from '@/lib/litegraph/src/infrastructure/NodeEventEmitter'
|
||||
Reference in New Issue
Block a user