diff --git a/src/extensions/core/dynamicPrompts.v2.ts b/src/extensions/core/dynamicPrompts.v2.ts new file mode 100644 index 0000000000..52f7783108 --- /dev/null +++ b/src/extensions/core/dynamicPrompts.v2.ts @@ -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() + return typeof value === 'string' + ? processDynamicPrompt(value) + : value + }) + } + } + } +}) diff --git a/src/extensions/core/imageCrop.v2.ts b/src/extensions/core/imageCrop.v2.ts new file mode 100644 index 0000000000..123b387f63 --- /dev/null +++ b/src/extensions/core/imageCrop.v2.ts @@ -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)]) + } +}) diff --git a/src/extensions/core/previewAny.v2.ts b/src/extensions/core/previewAny.v2.ts new file mode 100644 index 0000000000..d51ec97f1d --- /dev/null +++ b/src/extensions/core/previewAny.v2.ts @@ -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) + }) + } +}) diff --git a/src/services/extensionV2Service.ts b/src/services/extensionV2Service.ts new file mode 100644 index 0000000000..2c15325563 --- /dev/null +++ b/src/services/extensionV2Service.ts @@ -0,0 +1,413 @@ +/** + * 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() + +// should key be a template literal type like type EffectKey = `${string}:${number}` +// +// +// +// +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() // should this be lazy? like let _world = null;get world() { this._world ??= useWorld(); return this._world } + + return { + entityId: widgetId, + + // is "name" a constatnt variable name throughout our system? + get name() { + return world.getComponent(widgetId, WidgetIdentity).name + }, + + // Sort of want to just name this as type, might be bad though + get widgetType() { + return world.getComponent(widgetId, WidgetIdentity).type + }, + + getValue(): T { + return world.getComponent(widgetId, WidgetValue).value as T + }, + setValue(value: unknown) { + dispatch({ type: 'SetWidgetValue', widgetId, value }) + }, + + // why is hidden special and not just part of Options / props + // Consider: putting props into serialized form, persisting props + 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 }) + }, + + // what is special about label that it can't just be an option/prop? + setLabel(label: string) { + dispatch({ type: 'SetWidgetLabel', widgetId, label }) + }, + + on(event: string, fn: Function) { + // "change" feels like it could be watching options or values, when it in fact only watches value. but then how do ppl watch optoins and props? + if (event === 'change') { + watch( + () => world.getComponent(widgetId, WidgetValue).value, // this return is not nullish ever? + (newVal, oldVal) => fn(newVal, oldVal) // best practices for event dispatcher types in libraries + ) + } + if (event === 'removed') { + onScopeDispose(() => fn()) + } + }, + + // this seems like it should be `on("serialize")` to a certain extent. however, this is a callback/hook rather than watcher. + // maybe opportunity for onBeforeSerialize -- and then we don't actually operate on callbacks at all or pass the value before + // a serialization pipeline -- instead, they just access and mutate the model value (widget val) in the lexical scope of their + // hook on the event, and don't necessarily need to have the value passed to them etc. + 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 + // I believe these should all just be like `get pos(), get size(), get title(), etc. + 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 + }, + + // should be the get properties() layer, importance nuance here as this is typically the special properties set that is always + // and used for a ton of stuff by extensions currently for when they need to persist something across tear down cycles. + getProperty(key: string): T | undefined { + return world.getComponent(nodeId, NodeType).properties?.[key] as T + }, + getProperties() { + return { ...world.getComponent(nodeId, NodeType).properties } + }, + + // should just be get selected() right? + isSelected() { + return world.getComponent(nodeId, NodeVisual).selected ?? false + }, + + // Writes — command dispatches + // similar to above, should be like set pos(), set size(), set title(), etc. + 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 + // need a figler tree for widget_values + // need a coverage report overview of which class/instance properties and methods are strangler fig'ed, which are directly re-implemented + // (and extent to which they are 1:1 with the old shape/behaviors), and which are breaking / not supoprted anymore. + 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' } + ) +} diff --git a/src/types/extensionV2.ts b/src/types/extensionV2.ts new file mode 100644 index 0000000000..83ea9c6ff0 --- /dev/null +++ b/src/types/extensionV2.ts @@ -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 + 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(key: string): T | undefined + getProperties(): Record + 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 + ): WidgetHandle + + inputs(): readonly SlotInfo[] + outputs(): readonly SlotInfo[] + + on(event: 'removed', fn: () => void): void + on(event: 'executed', fn: (output: Record) => 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 + setup?(): void | Promise +}