mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
feat: extension v2 API proposal — NodeHandle/WidgetHandle with ECS bridge
Model the new extension API in code rather than docs:
- extensionV2.ts: Public types (NodeHandle, WidgetHandle, branded entity IDs,
defineNodeExtension/defineWidgetExtension)
- extensionV2Service.ts: Bridge layer that wraps LGraphNode/IBaseWidget in the
stable NodeHandle/WidgetHandle interfaces, with EffectScope-based lifecycle
management per extension+entity pair
- Three core extensions converted as proof-of-concept:
- previewAny.v2.ts (90 lines prototype patching -> 49 lines clean)
- dynamicPrompts.v2.ts
- imageCrop.v2.ts
This is pseudocode-grade -- it typechecks but is not wired into the app.
The bridge reads from LGraphNode directly; once ADR 0008 ECS World
exists, internals swap to World.getComponent() while NodeHandle stays
identical.
Key decisions modeled:
- Events over signals (node.on('executed', fn) not effect/derived)
- getValue/setValue over .value (widget.getValue() dispatches commands)
- nodeTypes filter over beforeRegisterNodeDef (no prototype patching)
- Automatic scope cleanup on entity removal
This commit is contained in:
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
337
src/services/extensionV2Service.ts
Normal file
337
src/services/extensionV2Service.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Extension V2 Service — wires the new extension API to the existing system.
|
||||
*
|
||||
* This is the bridge layer. It:
|
||||
* 1. Registers v2 extensions alongside v1 extensions
|
||||
* 2. Creates NodeHandle/WidgetHandle wrappers around LGraphNode/BaseWidget
|
||||
* 3. Manages EffectScopes per extension+entity for automatic cleanup
|
||||
* 4. Dispatches nodeCreated/loadedGraphNode hooks to v2 extensions
|
||||
*
|
||||
* The LGraphNode bridge will be replaced by direct ECS World access once
|
||||
* ADR 0008 is implemented. The NodeHandle interface stays the same.
|
||||
*/
|
||||
|
||||
import { EffectScope, onScopeDispose } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
NodeEntityId,
|
||||
NodeExtensionOptions,
|
||||
NodeHandle,
|
||||
Point,
|
||||
Size,
|
||||
SlotInfo,
|
||||
WidgetEntityId,
|
||||
WidgetExtensionOptions,
|
||||
WidgetHandle,
|
||||
WidgetOptions
|
||||
} from '@/types/extensionV2'
|
||||
|
||||
// ─── Scope Registry ──────────────────────────────────────────────────
|
||||
// One EffectScope per extension+node pair. When the node is removed,
|
||||
// scope.stop() auto-cleans all watchers, listeners, and derived state.
|
||||
|
||||
const scopeRegistry = new Map<string, EffectScope>()
|
||||
|
||||
function scopeKey(extensionName: string, entityId: number): string {
|
||||
return `${extensionName}:${entityId}`
|
||||
}
|
||||
|
||||
function getOrCreateScope(extensionName: string, entityId: number): EffectScope {
|
||||
const key = scopeKey(extensionName, entityId)
|
||||
let scope = scopeRegistry.get(key)
|
||||
if (!scope) {
|
||||
scope = new EffectScope(true)
|
||||
scopeRegistry.set(key, scope)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
function disposeScope(extensionName: string, entityId: number): void {
|
||||
const key = scopeKey(extensionName, entityId)
|
||||
const scope = scopeRegistry.get(key)
|
||||
if (scope) {
|
||||
scope.stop()
|
||||
scopeRegistry.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bridge: LGraphNode → NodeHandle ─────────────────────────────────
|
||||
// Wraps the legacy class in the stable public interface.
|
||||
// Once ECS World exists, this reads from World components instead.
|
||||
|
||||
let nextEntityId = 1
|
||||
const nodeEntityMap = new WeakMap<LGraphNode, NodeEntityId>()
|
||||
|
||||
function getNodeEntityId(node: LGraphNode): NodeEntityId {
|
||||
let id = nodeEntityMap.get(node)
|
||||
if (id === undefined) {
|
||||
id = nextEntityId++ as NodeEntityId
|
||||
nodeEntityMap.set(node, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
function createWidgetHandle(
|
||||
widget: IBaseWidget,
|
||||
_extensionName: string
|
||||
): WidgetHandle {
|
||||
const entityId = nextEntityId++ as WidgetEntityId
|
||||
|
||||
const listeners: Record<string, Set<Function>> = {}
|
||||
|
||||
const handle: WidgetHandle = {
|
||||
entityId,
|
||||
name: widget.name,
|
||||
widgetType: String(widget.type),
|
||||
|
||||
getValue<T = unknown>(): T {
|
||||
return widget.value as T
|
||||
},
|
||||
setValue(value: unknown) {
|
||||
const old = widget.value
|
||||
widget.value = value as typeof widget.value
|
||||
listeners['change']?.forEach((fn) => fn(value, old))
|
||||
},
|
||||
|
||||
isHidden() {
|
||||
return widget.hidden ?? false
|
||||
},
|
||||
setHidden(hidden: boolean) {
|
||||
widget.hidden = hidden
|
||||
if (widget.options) widget.options.hidden = hidden
|
||||
},
|
||||
|
||||
getOptions(): WidgetOptions {
|
||||
return { ...widget.options } as WidgetOptions
|
||||
},
|
||||
setOption(key: string, value: unknown) {
|
||||
if (widget.options) {
|
||||
;(widget.options as Record<string, unknown>)[key] = value
|
||||
}
|
||||
},
|
||||
setLabel(label: string) {
|
||||
widget.label = label
|
||||
},
|
||||
|
||||
on(event: string, fn: Function) {
|
||||
if (!listeners[event]) listeners[event] = new Set()
|
||||
listeners[event].add(fn)
|
||||
},
|
||||
|
||||
setSerializeValue(
|
||||
fn: (workflowNode: unknown, widgetIndex: number) => unknown
|
||||
) {
|
||||
widget.serializeValue = fn as never
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge: wire legacy widget.callback to 'change' event
|
||||
const origCallback = widget.callback
|
||||
widget.callback = (...args: unknown[]) => {
|
||||
;(origCallback as Function)?.call(widget, ...args)
|
||||
const old = widget.value
|
||||
listeners['change']?.forEach((fn) => fn(args[0], old))
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
function createNodeHandle(
|
||||
node: LGraphNode,
|
||||
extensionName: string
|
||||
): NodeHandle {
|
||||
const entityId = getNodeEntityId(node)
|
||||
const listeners: Record<string, Set<Function>> = {}
|
||||
|
||||
function on(event: string, fn: Function) {
|
||||
if (!listeners[event]) listeners[event] = new Set()
|
||||
listeners[event].add(fn)
|
||||
|
||||
// Auto-cleanup when scope stops
|
||||
onScopeDispose(() => {
|
||||
listeners[event]?.delete(fn)
|
||||
})
|
||||
}
|
||||
|
||||
// Wire legacy LGraphNode callbacks → event dispatch.
|
||||
// Once ECS exists, these become World component watches instead.
|
||||
const origOnRemoved = node.onRemoved
|
||||
node.onRemoved = () => {
|
||||
origOnRemoved?.call(node)
|
||||
listeners['removed']?.forEach((fn) => fn())
|
||||
disposeScope(extensionName, entityId)
|
||||
}
|
||||
|
||||
const origOnExecuted = node.onExecuted
|
||||
node.onExecuted = (output: Record<string, unknown>) => {
|
||||
origOnExecuted?.call(node, output)
|
||||
listeners['executed']?.forEach((fn) => fn(output))
|
||||
}
|
||||
|
||||
const origOnConfigure = node.onConfigure
|
||||
node.onConfigure = function (this: LGraphNode, ...args: unknown[]) {
|
||||
origOnConfigure?.apply(this, args as never)
|
||||
listeners['configured']?.forEach((fn) => fn())
|
||||
}
|
||||
|
||||
const handle: NodeHandle = {
|
||||
entityId,
|
||||
type: node.type ?? '',
|
||||
comfyClass: (node.constructor as { comfyClass?: string }).comfyClass ?? '',
|
||||
|
||||
// ─── Reads (will become World.getComponent queries) ──────
|
||||
getPosition(): Point {
|
||||
return [node.pos[0], node.pos[1]]
|
||||
},
|
||||
getSize(): Size {
|
||||
return [node.size[0], node.size[1]]
|
||||
},
|
||||
getTitle() {
|
||||
return node.title
|
||||
},
|
||||
getMode() {
|
||||
return node.mode
|
||||
},
|
||||
getProperty<T = unknown>(key: string): T | undefined {
|
||||
return node.properties?.[key] as T
|
||||
},
|
||||
getProperties() {
|
||||
return { ...node.properties }
|
||||
},
|
||||
isSelected() {
|
||||
return node.is_selected ?? false
|
||||
},
|
||||
|
||||
// ─── Writes (will become command dispatches) ─────────────
|
||||
setPosition(pos: Point) {
|
||||
node.pos[0] = pos[0]
|
||||
node.pos[1] = pos[1]
|
||||
listeners['positionChanged']?.forEach((fn) => fn(pos))
|
||||
},
|
||||
setSize(size: Size) {
|
||||
node.setSize(size)
|
||||
listeners['sizeChanged']?.forEach((fn) => fn(size))
|
||||
},
|
||||
setTitle(title: string) {
|
||||
node.title = title
|
||||
},
|
||||
setMode(mode) {
|
||||
node.mode = mode
|
||||
listeners['modeChanged']?.forEach((fn) => fn(mode))
|
||||
},
|
||||
setProperty(key: string, value: unknown) {
|
||||
if (!node.properties) node.properties = {}
|
||||
;(node.properties as Record<string, unknown>)[key] = value
|
||||
},
|
||||
|
||||
// ─── Widgets ─────────────────────────────────────────────
|
||||
widget(name: string) {
|
||||
const w = node.widgets?.find((w) => w.name === name)
|
||||
return w ? createWidgetHandle(w, extensionName) : undefined
|
||||
},
|
||||
widgets() {
|
||||
return (node.widgets ?? []).map((w) => createWidgetHandle(w, extensionName))
|
||||
},
|
||||
addWidget(
|
||||
type: string,
|
||||
name: string,
|
||||
defaultValue: unknown,
|
||||
options?: Partial<WidgetOptions>
|
||||
) {
|
||||
// Bridge to legacy widget creation.
|
||||
// TODO: Replace with ECS entity creation once World exists.
|
||||
const widget = node.addCustomWidget({
|
||||
name,
|
||||
type,
|
||||
value: defaultValue,
|
||||
options: options ?? {},
|
||||
draw: () => {},
|
||||
computeSize: () => [0, 20]
|
||||
} as unknown as Parameters<typeof node.addCustomWidget>[0])
|
||||
return createWidgetHandle(widget, extensionName)
|
||||
},
|
||||
|
||||
// ─── Slots ───────────────────────────────────────────────
|
||||
inputs() {
|
||||
return (node.inputs ?? []).map((input) => ({
|
||||
entityId: nextEntityId++ as unknown as SlotInfo['entityId'],
|
||||
name: input.name,
|
||||
type: String(input.type),
|
||||
direction: 'input' as const,
|
||||
nodeEntityId: entityId
|
||||
}))
|
||||
},
|
||||
outputs() {
|
||||
return (node.outputs ?? []).map((output) => ({
|
||||
entityId: nextEntityId++ as unknown as SlotInfo['entityId'],
|
||||
name: output.name,
|
||||
type: String(output.type),
|
||||
direction: 'output' as const,
|
||||
nodeEntityId: entityId
|
||||
}))
|
||||
},
|
||||
|
||||
on: on as NodeHandle['on']
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the framework when a node is created.
|
||||
* Dispatches to all registered v2 node extensions.
|
||||
*/
|
||||
export function dispatchNodeCreated(node: LGraphNode): void {
|
||||
const comfyClass =
|
||||
(node.constructor as { comfyClass?: string }).comfyClass ?? ''
|
||||
|
||||
for (const ext of nodeExtensions) {
|
||||
// Skip if this extension filters to specific types and this node isn't one
|
||||
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
|
||||
if (!ext.nodeCreated) continue
|
||||
|
||||
const entityId = getNodeEntityId(node)
|
||||
const scope = getOrCreateScope(ext.name, entityId)
|
||||
|
||||
scope.run(() => {
|
||||
const handle = createNodeHandle(node, ext.name)
|
||||
ext.nodeCreated!(handle)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the framework when a node is loaded from a saved workflow.
|
||||
*/
|
||||
export function dispatchLoadedGraphNode(node: LGraphNode): void {
|
||||
const comfyClass =
|
||||
(node.constructor as { comfyClass?: string }).comfyClass ?? ''
|
||||
|
||||
for (const ext of nodeExtensions) {
|
||||
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
|
||||
if (!ext.loadedGraphNode) continue
|
||||
|
||||
const entityId = getNodeEntityId(node)
|
||||
const scope = getOrCreateScope(ext.name, entityId)
|
||||
|
||||
scope.run(() => {
|
||||
const handle = createNodeHandle(node, ext.name)
|
||||
ext.loadedGraphNode!(handle)
|
||||
})
|
||||
}
|
||||
}
|
||||
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