mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-15 12:11:06 +00:00
Compare commits
3 Commits
feat/node-
...
extension-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab3f218e2 | ||
|
|
2f8fe0013b | ||
|
|
fa2f79ad3a |
26
src/extensions/core/dynamicPrompts.v2.ts
Normal file
26
src/extensions/core/dynamicPrompts.v2.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* DynamicPrompts — rewritten with the v2 extension API.
|
||||
*
|
||||
* v1: reads node.widgets, assigns widget.serializeValue
|
||||
* v2: same logic, uses WidgetHandle instead of raw widget
|
||||
*/
|
||||
|
||||
import { defineNodeExtension } from '@/services/extensionV2Service'
|
||||
import { processDynamicPrompt } from '@/utils/formatUtil'
|
||||
|
||||
defineNodeExtension({
|
||||
name: 'Comfy.DynamicPrompts.V2',
|
||||
|
||||
nodeCreated(node) {
|
||||
for (const widget of node.widgets()) {
|
||||
if (widget.getOptions().dynamicPrompts) {
|
||||
widget.setSerializeValue((_workflowNode, _widgetIndex) => {
|
||||
const value = widget.getValue<string>()
|
||||
return typeof value === 'string'
|
||||
? processDynamicPrompt(value)
|
||||
: value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
18
src/extensions/core/imageCrop.v2.ts
Normal file
18
src/extensions/core/imageCrop.v2.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* ImageCrop — rewritten with the v2 extension API.
|
||||
*
|
||||
* v1: 13 lines, accesses node.size and node.constructor.comfyClass directly
|
||||
* v2: 12 lines, uses NodeHandle — type filtering via nodeTypes option
|
||||
*/
|
||||
|
||||
import { defineNodeExtension } from '@/services/extensionV2Service'
|
||||
|
||||
defineNodeExtension({
|
||||
name: 'Comfy.ImageCrop.V2',
|
||||
nodeTypes: ['ImageCropV2'],
|
||||
|
||||
nodeCreated(node) {
|
||||
const [w, h] = node.getSize()
|
||||
node.setSize([Math.max(w, 300), Math.max(h, 450)])
|
||||
}
|
||||
})
|
||||
49
src/extensions/core/previewAny.v2.ts
Normal file
49
src/extensions/core/previewAny.v2.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* PreviewAny — rewritten with the v2 extension API.
|
||||
*
|
||||
* Compare with previewAny.ts (v1) which uses beforeRegisterNodeDef +
|
||||
* prototype patching + manual callback chaining.
|
||||
*
|
||||
* v1: 90 lines, prototype.onNodeCreated override, prototype.onExecuted override
|
||||
* v2: 35 lines, no prototype access, no manual chaining
|
||||
*/
|
||||
|
||||
import { defineNodeExtension } from '@/services/extensionV2Service'
|
||||
|
||||
defineNodeExtension({
|
||||
name: 'Comfy.PreviewAny.V2',
|
||||
nodeTypes: ['PreviewAny'],
|
||||
|
||||
nodeCreated(node) {
|
||||
const markdown = node.addWidget('MARKDOWN', 'preview_markdown', '', {
|
||||
hidden: true,
|
||||
readonly: true,
|
||||
serialize: false
|
||||
})
|
||||
markdown.setLabel('Preview')
|
||||
|
||||
const plaintext = node.addWidget('STRING', 'preview_text', '', {
|
||||
multiline: true,
|
||||
readonly: true,
|
||||
serialize: false
|
||||
})
|
||||
plaintext.setLabel('Preview')
|
||||
|
||||
const toggle = node.addWidget('BOOLEAN', 'previewMode', false, {
|
||||
labelOn: 'Markdown',
|
||||
labelOff: 'Plaintext'
|
||||
})
|
||||
|
||||
toggle.on('change', (value) => {
|
||||
markdown.setHidden(!value)
|
||||
plaintext.setHidden(value as boolean)
|
||||
})
|
||||
|
||||
node.on('executed', (output) => {
|
||||
const text = (output.text as string | string[]) ?? ''
|
||||
const content = Array.isArray(text) ? text.join('\n\n') : text
|
||||
markdown.setValue(content)
|
||||
plaintext.setValue(content)
|
||||
})
|
||||
}
|
||||
})
|
||||
384
src/services/extensionV2Service.ts
Normal file
384
src/services/extensionV2Service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Extension V2 Service
|
||||
*
|
||||
* Manages extension lifecycle: scope creation, handle construction,
|
||||
* reactive dispatch. The extension system watches the ECS World for
|
||||
* entity creation/removal and auto-mounts/unmounts extension scopes.
|
||||
*
|
||||
* Pattern mirrors Vue's setupStatefulComponent (component.ts:829-927):
|
||||
* - scope.run() activates the EffectScope (like setCurrentInstance)
|
||||
* - pauseTracking() prevents accidental deps during setup
|
||||
* - All watches/effects created inside scope.run() are captured
|
||||
* - scope.stop() on entity removal cleans up everything
|
||||
*
|
||||
* See decisions/D3.5-reactive-dispatch-and-scope-alignment.md
|
||||
*/
|
||||
|
||||
import {
|
||||
EffectScope,
|
||||
onScopeDispose,
|
||||
pauseTracking,
|
||||
resetTracking,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
// These modules don't exist yet — they will be created as part of ADR 0008.
|
||||
import { useWorld } from '@/ecs/world'
|
||||
import { dispatch } from '@/ecs/commands'
|
||||
import {
|
||||
Position,
|
||||
Dimensions,
|
||||
NodeVisual,
|
||||
NodeType,
|
||||
Execution,
|
||||
WidgetValue,
|
||||
WidgetIdentity,
|
||||
WidgetContainer,
|
||||
Connectivity,
|
||||
SlotIdentity,
|
||||
LoadedFromWorkflow
|
||||
} from '@/ecs/components'
|
||||
import type {
|
||||
NodeEntityId,
|
||||
WidgetEntityId,
|
||||
SlotEntityId
|
||||
} from '@/ecs/entityIds'
|
||||
|
||||
import type {
|
||||
NodeExtensionOptions,
|
||||
NodeHandle,
|
||||
WidgetExtensionOptions,
|
||||
WidgetHandle,
|
||||
WidgetOptions,
|
||||
SlotInfo,
|
||||
Point,
|
||||
Size
|
||||
} from '@/types/extensionV2'
|
||||
|
||||
// ─── Scope Registry ──────────────────────────────────────────────────
|
||||
// One EffectScope per extension+entity pair. Disposed when the entity is
|
||||
// removed from the World (detected by the reactive mount watcher).
|
||||
|
||||
const scopeRegistry = new Map<string, EffectScope>()
|
||||
|
||||
function getOrCreateScope(
|
||||
extensionName: string,
|
||||
entityId: number
|
||||
): EffectScope {
|
||||
const key = `${extensionName}:${entityId}`
|
||||
let scope = scopeRegistry.get(key)
|
||||
if (!scope) {
|
||||
scope = new EffectScope(true)
|
||||
scopeRegistry.set(key, scope)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
function stopScope(extensionName: string, entityId: number): void {
|
||||
const key = `${extensionName}:${entityId}`
|
||||
const scope = scopeRegistry.get(key)
|
||||
if (scope) {
|
||||
scope.stop()
|
||||
scopeRegistry.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WidgetHandle ────────────────────────────────────────────────────
|
||||
|
||||
function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
|
||||
const world = useWorld()
|
||||
|
||||
return {
|
||||
entityId: widgetId,
|
||||
get name() {
|
||||
return world.getComponent(widgetId, WidgetIdentity).name
|
||||
},
|
||||
get widgetType() {
|
||||
return world.getComponent(widgetId, WidgetIdentity).type
|
||||
},
|
||||
|
||||
getValue<T = unknown>(): T {
|
||||
return world.getComponent(widgetId, WidgetValue).value as T
|
||||
},
|
||||
setValue(value: unknown) {
|
||||
dispatch({ type: 'SetWidgetValue', widgetId, value })
|
||||
},
|
||||
|
||||
isHidden() {
|
||||
return (
|
||||
world.getComponent(widgetId, WidgetValue).options?.hidden ?? false
|
||||
)
|
||||
},
|
||||
setHidden(hidden: boolean) {
|
||||
dispatch({
|
||||
type: 'SetWidgetOption',
|
||||
widgetId,
|
||||
key: 'hidden',
|
||||
value: hidden
|
||||
})
|
||||
},
|
||||
getOptions(): WidgetOptions {
|
||||
return world.getComponent(widgetId, WidgetValue).options ?? {}
|
||||
},
|
||||
setOption(key: string, value: unknown) {
|
||||
dispatch({ type: 'SetWidgetOption', widgetId, key, value })
|
||||
},
|
||||
setLabel(label: string) {
|
||||
dispatch({ type: 'SetWidgetLabel', widgetId, label })
|
||||
},
|
||||
|
||||
on(event: string, fn: Function) {
|
||||
if (event === 'change') {
|
||||
watch(
|
||||
() => world.getComponent(widgetId, WidgetValue).value,
|
||||
(newVal, oldVal) => fn(newVal, oldVal)
|
||||
)
|
||||
}
|
||||
if (event === 'removed') {
|
||||
onScopeDispose(() => fn())
|
||||
}
|
||||
},
|
||||
|
||||
setSerializeValue(fn) {
|
||||
dispatch({ type: 'SetWidgetSerializer', widgetId, serializer: fn })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NodeHandle ──────────────────────────────────────────────────────
|
||||
|
||||
function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
const world = useWorld()
|
||||
|
||||
return {
|
||||
entityId: nodeId,
|
||||
get type() {
|
||||
return world.getComponent(nodeId, NodeType).type
|
||||
},
|
||||
get comfyClass() {
|
||||
return world.getComponent(nodeId, NodeType).comfyClass
|
||||
},
|
||||
|
||||
// Reads — direct World queries
|
||||
getPosition(): Point {
|
||||
return world.getComponent(nodeId, Position).pos
|
||||
},
|
||||
getSize(): Size {
|
||||
return world.getComponent(nodeId, Dimensions).size
|
||||
},
|
||||
getTitle() {
|
||||
return world.getComponent(nodeId, NodeVisual).title
|
||||
},
|
||||
getMode() {
|
||||
return world.getComponent(nodeId, Execution).mode
|
||||
},
|
||||
getProperty<T = unknown>(key: string): T | undefined {
|
||||
return world.getComponent(nodeId, NodeType).properties?.[key] as T
|
||||
},
|
||||
getProperties() {
|
||||
return { ...world.getComponent(nodeId, NodeType).properties }
|
||||
},
|
||||
isSelected() {
|
||||
return world.getComponent(nodeId, NodeVisual).selected ?? false
|
||||
},
|
||||
|
||||
// Writes — command dispatches
|
||||
setPosition(pos: Point) {
|
||||
dispatch({ type: 'MoveNode', nodeId, pos })
|
||||
},
|
||||
setSize(size: Size) {
|
||||
dispatch({ type: 'ResizeNode', nodeId, size })
|
||||
},
|
||||
setTitle(title: string) {
|
||||
dispatch({ type: 'SetNodeVisual', nodeId, patch: { title } })
|
||||
},
|
||||
setMode(mode) {
|
||||
dispatch({ type: 'SetNodeMode', nodeId, mode })
|
||||
},
|
||||
setProperty(key: string, value: unknown) {
|
||||
dispatch({ type: 'SetNodeProperty', nodeId, key, value })
|
||||
},
|
||||
|
||||
// Widgets
|
||||
widget(name: string) {
|
||||
const container = world.getComponent(nodeId, WidgetContainer)
|
||||
const widgetId = container.widgetIds.find((id) => {
|
||||
return world.getComponent(id, WidgetIdentity).name === name
|
||||
})
|
||||
return widgetId ? createWidgetHandle(widgetId) : undefined
|
||||
},
|
||||
widgets() {
|
||||
const container = world.getComponent(nodeId, WidgetContainer)
|
||||
return container.widgetIds.map(createWidgetHandle)
|
||||
},
|
||||
addWidget(type, name, defaultValue, options) {
|
||||
const widgetId = dispatch({
|
||||
type: 'CreateWidget',
|
||||
parentNodeId: nodeId,
|
||||
widgetType: type,
|
||||
name,
|
||||
defaultValue,
|
||||
options
|
||||
})
|
||||
return createWidgetHandle(widgetId)
|
||||
},
|
||||
|
||||
// Slots
|
||||
inputs() {
|
||||
const conn = world.getComponent(nodeId, Connectivity)
|
||||
return conn.inputSlotIds.map((slotId) => {
|
||||
const slot = world.getComponent(slotId, SlotIdentity)
|
||||
return {
|
||||
entityId: slotId,
|
||||
name: slot.name,
|
||||
type: slot.type,
|
||||
direction: 'input' as const,
|
||||
nodeEntityId: nodeId
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
outputs() {
|
||||
const conn = world.getComponent(nodeId, Connectivity)
|
||||
return conn.outputSlotIds.map((slotId) => {
|
||||
const slot = world.getComponent(slotId, SlotIdentity)
|
||||
return {
|
||||
entityId: slotId,
|
||||
name: slot.name,
|
||||
type: slot.type,
|
||||
direction: 'output' as const,
|
||||
nodeEntityId: nodeId
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
|
||||
// Events — backed by World component watches
|
||||
on(event: string, fn: Function) {
|
||||
if (event === 'positionChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Position).pos,
|
||||
(pos) => fn(pos)
|
||||
)
|
||||
} else if (event === 'sizeChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Dimensions).size,
|
||||
(s) => fn(s)
|
||||
)
|
||||
} else if (event === 'modeChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Execution).mode,
|
||||
(m) => fn(m)
|
||||
)
|
||||
} else if (event === 'executed') {
|
||||
world.onSystemEvent(nodeId, 'executed', fn)
|
||||
} else if (event === 'connected') {
|
||||
world.onSystemEvent(nodeId, 'connected', fn)
|
||||
} else if (event === 'disconnected') {
|
||||
world.onSystemEvent(nodeId, 'disconnected', fn)
|
||||
} else if (event === 'configured') {
|
||||
world.onSystemEvent(nodeId, 'configured', fn)
|
||||
} else if (event === 'removed') {
|
||||
onScopeDispose(() => fn())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extension Registry ──────────────────────────────────────────────
|
||||
|
||||
const nodeExtensions: NodeExtensionOptions[] = []
|
||||
const widgetExtensions: WidgetExtensionOptions[] = []
|
||||
|
||||
export function defineNodeExtension(options: NodeExtensionOptions): void {
|
||||
nodeExtensions.push(options)
|
||||
}
|
||||
|
||||
export function defineWidgetExtension(options: WidgetExtensionOptions): void {
|
||||
widgetExtensions.push(options)
|
||||
}
|
||||
|
||||
// ─── Reactive Mount System ───────────────────────────────────────────
|
||||
// Watches the World for entity creation/removal. When a NodeType
|
||||
// component appears, extensions are mounted for that entity. When it
|
||||
// disappears, all extension scopes for that entity are stopped.
|
||||
//
|
||||
// This replaces the imperative dispatchNodeCreated/dispatchLoadedGraphNode
|
||||
// pattern. The World is the single source of truth — if an entity
|
||||
// exists, its extensions are mounted.
|
||||
|
||||
/**
|
||||
* Mount extensions for a newly detected node entity.
|
||||
*
|
||||
* Follows Vue's setupStatefulComponent pattern:
|
||||
* 1. scope.run() activates the EffectScope
|
||||
* 2. pauseTracking() prevents accidental dependency tracking
|
||||
* 3. Extension hook runs — explicit watches via node.on() are captured
|
||||
* 4. resetTracking() restores tracking state
|
||||
*/
|
||||
function mountExtensionsForNode(nodeId: NodeEntityId): void {
|
||||
const world = useWorld()
|
||||
const comfyClass = world.getComponent(nodeId, NodeType).comfyClass
|
||||
const isLoaded = world.hasComponent(nodeId, LoadedFromWorkflow)
|
||||
|
||||
for (const ext of nodeExtensions) {
|
||||
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
|
||||
|
||||
const hook = isLoaded ? ext.loadedGraphNode : ext.nodeCreated
|
||||
if (!hook) continue
|
||||
|
||||
const scope = getOrCreateScope(ext.name, nodeId)
|
||||
scope.run(() => {
|
||||
pauseTracking()
|
||||
try {
|
||||
hook(createNodeHandle(nodeId))
|
||||
} finally {
|
||||
resetTracking()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount all extension scopes for a removed node entity.
|
||||
* scope.stop() disposes all watches, computed, and onScopeDispose
|
||||
* callbacks created during the extension's setup.
|
||||
*/
|
||||
function unmountExtensionsForNode(nodeId: NodeEntityId): void {
|
||||
for (const ext of nodeExtensions) {
|
||||
stopScope(ext.name, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the reactive extension mount system.
|
||||
*
|
||||
* Called once during app initialization. Watches the World's entity list
|
||||
* and auto-mounts/unmounts extensions as entities appear/disappear.
|
||||
*
|
||||
* This means no code path (add node, paste, load workflow, undo, CRDT
|
||||
* sync) needs to manually call a dispatch function — the World is the
|
||||
* single source of truth.
|
||||
*/
|
||||
export function startExtensionSystem(): void {
|
||||
const world = useWorld()
|
||||
|
||||
watch(
|
||||
() => world.queryAll(NodeType),
|
||||
(currentIds, previousIds) => {
|
||||
const prev = new Set(previousIds ?? [])
|
||||
const curr = new Set(currentIds)
|
||||
|
||||
for (const nodeId of currentIds) {
|
||||
if (!prev.has(nodeId)) {
|
||||
mountExtensionsForNode(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of previousIds ?? []) {
|
||||
if (!curr.has(nodeId)) {
|
||||
unmountExtensionsForNode(nodeId)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
}
|
||||
169
src/types/extensionV2.ts
Normal file
169
src/types/extensionV2.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Extension V2 API — public types for the new extension system.
|
||||
*
|
||||
* This is the stable public contract. Extensions depend on these types.
|
||||
* Internal implementation (ECS World, commands, scopes) is hidden.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// ─── Branded Entity IDs ──────────────────────────────────────────────
|
||||
// Compile-time distinct. Prevents passing a WidgetEntityId where a
|
||||
// NodeEntityId is expected. The underlying value is always `number`.
|
||||
|
||||
export type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
export type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
export type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
export type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
|
||||
// ─── Geometry ────────────────────────────────────────────────────────
|
||||
|
||||
export type Point = [x: number, y: number]
|
||||
export type Size = [width: number, height: number]
|
||||
|
||||
// ─── Enums ───────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeMode = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export type SlotDirection = 'input' | 'output'
|
||||
|
||||
// ─── Slot Info ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SlotInfo {
|
||||
readonly entityId: SlotEntityId
|
||||
readonly name: string
|
||||
readonly type: string
|
||||
readonly direction: SlotDirection
|
||||
readonly nodeEntityId: NodeEntityId
|
||||
}
|
||||
|
||||
// ─── Widget Options ──────────────────────────────────────────────────
|
||||
|
||||
export interface WidgetOptions {
|
||||
readonly?: boolean
|
||||
multiline?: boolean
|
||||
hidden?: boolean
|
||||
serialize?: boolean
|
||||
labelOn?: string
|
||||
labelOff?: string
|
||||
default?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ─── WidgetHandle ────────────────────────────────────────────────────
|
||||
// Controlled surface for widget access. Backed by WidgetValue component
|
||||
// in the ECS World. getValue/setValue dispatch commands internally.
|
||||
// All views (node, properties panel, promoted copy) share the same
|
||||
// backing WidgetEntityId, so changes from any source fire 'change'.
|
||||
|
||||
export interface WidgetHandle {
|
||||
readonly entityId: WidgetEntityId
|
||||
readonly name: string
|
||||
readonly widgetType: string
|
||||
|
||||
getValue<T = unknown>(): T
|
||||
setValue(value: unknown): void
|
||||
|
||||
isHidden(): boolean
|
||||
setHidden(hidden: boolean): void
|
||||
getOptions(): WidgetOptions
|
||||
setOption(key: string, value: unknown): void
|
||||
setLabel(label: string): void
|
||||
|
||||
on(event: 'change', fn: (value: unknown, oldValue: unknown) => void): void
|
||||
on(event: 'removed', fn: () => void): void
|
||||
|
||||
setSerializeValue(
|
||||
fn: (workflowNode: unknown, widgetIndex: number) => unknown
|
||||
): void
|
||||
}
|
||||
|
||||
// ─── NodeHandle ──────────────────────────────────────────────────────
|
||||
// Controlled surface for node access. Backed by ECS components.
|
||||
// Getters query the World. Setters dispatch commands (undo-able,
|
||||
// serializable, validatable). Events are World component subscriptions
|
||||
// dispatched through Vue reactivity internally.
|
||||
|
||||
export interface NodeHandle {
|
||||
readonly entityId: NodeEntityId
|
||||
readonly type: string
|
||||
readonly comfyClass: string
|
||||
|
||||
getPosition(): Point
|
||||
getSize(): Size
|
||||
getTitle(): string
|
||||
getMode(): NodeMode
|
||||
getProperty<T = unknown>(key: string): T | undefined
|
||||
getProperties(): Record<string, unknown>
|
||||
isSelected(): boolean
|
||||
|
||||
setPosition(pos: Point): void
|
||||
setSize(size: Size): void
|
||||
setTitle(title: string): void
|
||||
setMode(mode: NodeMode): void
|
||||
setProperty(key: string, value: unknown): void
|
||||
|
||||
widget(name: string): WidgetHandle | undefined
|
||||
widgets(): readonly WidgetHandle[]
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
defaultValue: unknown,
|
||||
options?: Partial<WidgetOptions>
|
||||
): WidgetHandle
|
||||
|
||||
inputs(): readonly SlotInfo[]
|
||||
outputs(): readonly SlotInfo[]
|
||||
|
||||
on(event: 'removed', fn: () => void): void
|
||||
on(event: 'executed', fn: (output: Record<string, unknown>) => void): void
|
||||
on(event: 'configured', fn: () => void): void
|
||||
on(
|
||||
event: 'connected',
|
||||
fn: (slot: SlotInfo, remote: SlotInfo) => void
|
||||
): void
|
||||
on(event: 'disconnected', fn: (slot: SlotInfo) => void): void
|
||||
on(event: 'positionChanged', fn: (pos: Point) => void): void
|
||||
on(event: 'sizeChanged', fn: (size: Size) => void): void
|
||||
on(event: 'modeChanged', fn: (mode: NodeMode) => void): void
|
||||
}
|
||||
|
||||
// ─── Extension Registration ─────────────────────────────────────────
|
||||
|
||||
export interface NodeExtensionOptions {
|
||||
/** Unique extension name */
|
||||
name: string
|
||||
/** Filter to specific comfyClass names. Omit = runs for all nodes. */
|
||||
nodeTypes?: string[]
|
||||
|
||||
/** Runs once per node instance. Everything declared here is scoped to the node's lifetime. */
|
||||
nodeCreated?(node: NodeHandle): void
|
||||
/** Runs for nodes loaded from a saved workflow. */
|
||||
loadedGraphNode?(node: NodeHandle): void
|
||||
}
|
||||
|
||||
export interface WidgetExtensionOptions {
|
||||
/** Unique extension name */
|
||||
name: string
|
||||
/** Widget type string this extension provides (e.g., 'COLOR_PICKER') */
|
||||
type: string
|
||||
|
||||
/** Runs once per widget instance. Return a render/destroy pair for custom DOM. */
|
||||
widgetCreated?(
|
||||
widget: WidgetHandle,
|
||||
parentNode: NodeHandle | null
|
||||
):
|
||||
| {
|
||||
render(container: HTMLElement): void
|
||||
destroy?(): void
|
||||
}
|
||||
| void
|
||||
}
|
||||
|
||||
// Re-export ComfyExtension-compatible options for global lifecycle.
|
||||
// Commands, keybindings, settings, etc. are unchanged from v1.
|
||||
export interface ExtensionOptions {
|
||||
name: string
|
||||
init?(): void | Promise<void>
|
||||
setup?(): void | Promise<void>
|
||||
}
|
||||
Reference in New Issue
Block a user