Compare commits

..

3 Commits

Author SHA1 Message Date
bymyself
cab3f218e2 refactor: reactive extension mounting with Vue setupStatefulComponent pattern
- Replace imperative dispatchNodeCreated/dispatchLoadedGraphNode with
  startExtensionSystem() — a single watcher on world.queryAll(NodeType)
- Extensions mount/unmount as reactive consequence of World state changes
- Add pauseTracking/resetTracking around hook execution (matches Vue's
  setupStatefulComponent to prevent accidental dependency tracking)
- Use LoadedFromWorkflow tag component to select nodeCreated vs
  loadedGraphNode hook
- flush: 'post' ensures extensions mount after DOM updates
2026-04-14 21:33:25 -07:00
bymyself
2f8fe0013b refactor: replace bridge layer with direct ECS imports
Pretend the ECS world/command layer already exists. Import from
@/ecs/world, @/ecs/commands, @/ecs/components, @/ecs/entityIds —
these modules don't exist yet and this won't compile.

The point: show what the service looks like when it uses World
queries and command dispatches directly, with zero LGraphNode
bridge code. NodeHandle/WidgetHandle interface unchanged.
2026-04-14 21:20:07 -07:00
bymyself
fa2f79ad3a 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
2026-04-14 21:09:27 -07:00
29 changed files with 933 additions and 1001 deletions

View File

@@ -1,77 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
}
)

531
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -3,19 +3,19 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
</div>
</template>
@@ -25,10 +25,6 @@ import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const { disabled = false } = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})

View File

@@ -3,13 +3,8 @@
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
:class="
cn(
'aspect-square w-full rounded-[5px] bg-node-component-surface',
disabled ? 'cursor-default' : 'cursor-crosshair'
)
"
@pointerdown.stop="onSvgPointerDown"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
@contextmenu.prevent.stop
>
<line
@@ -61,23 +56,20 @@
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
:opacity="disabled ? 0.5 : 1"
/>
<template v-if="!disabled">
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</template>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</svg>
</template>
@@ -85,20 +77,14 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const {
curveColor = 'white',
histogram,
disabled = false
} = defineProps<{
const { curveColor = 'white', histogram } = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -112,10 +98,6 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
modelValue
})
function onSvgPointerDown(e: PointerEvent) {
if (!disabled) handleSvgPointerDown(e)
}
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)

View File

@@ -1,27 +1,11 @@
<template>
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
<CurveEditor v-model="modelValue" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
import CurveEditor from './CurveEditor.vue'
const modelValue = defineModel<CurvePoint[]>({
default: () => [
@@ -29,17 +13,4 @@ const modelValue = defineModel<CurvePoint[]>({
[1, 1]
]
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
)
const effectivePoints = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
</script>

View File

@@ -1,19 +1,5 @@
import type { CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points

View File

@@ -381,17 +381,10 @@ watch(
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
* returns a new `Record` object on every progress event (the underlying
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
*
* `currentGraph` triggers this watcher on subgraph navigation so stale
* progress bars are cleared when returning to the root graph.
*/
watch(
() =>
[
executionStore.nodeLocationProgressStates,
canvasStore.canvas,
canvasStore.currentGraph
] as const,
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
@@ -570,8 +563,10 @@ onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (!isMiddlePointerInput(e)) return
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
return
canvasInteractions.forwardEventToCanvas(e)

View File

@@ -36,37 +36,26 @@
<div
v-if="imageUrl && !isLoading"
:class="
cn(
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="imageUrl && !isLoading"
:class="
cn(
'absolute',
handle.class,
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</template>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</div>
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
<div class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
@@ -101,16 +90,12 @@
</Button>
</div>
<WidgetBoundingBox
v-model="effectiveBounds"
:disabled="isDisabled"
class="shrink-0"
/>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue'
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -120,17 +105,10 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget
const props = defineProps<{
nodeId: NodeId
}>()
@@ -138,23 +116,6 @@ const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
boundsExtractor()
)
const effectiveBounds = computed({
get: () =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value,
set: (v) => {
if (!isDisabled.value) modelValue.value = v
}
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
@@ -178,5 +139,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -41,8 +41,6 @@ import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originOutputName?: string
}
/**
@@ -357,36 +355,6 @@ function safeWidgetMapper(
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -459,11 +427,15 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
node.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -516,7 +488,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
nodeRef.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {

View File

@@ -1,118 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
}
const isNumber = (v: unknown): v is number => typeof v === 'number'
describe('singleValueExtractor', () => {
const extract = singleValueExtractor(isNumber)
it('matches widget by outputName', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'b')).toBe(42)
})
it('returns undefined when outputName widget has invalid value', () => {
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
expect(extract(widgets, 'b')).toBeUndefined()
})
it('falls back to unique valid widget when outputName has no match', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'missing')).toBe(42)
})
it('falls back to unique valid widget when no outputName provided', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, undefined)).toBe(42)
})
it('returns undefined when multiple widgets have valid values', () => {
const widgets = [widget('a', 1), widget('b', 2)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when no widgets have valid values', () => {
const widgets = [widget('a', 'text')]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
})
describe('boundsExtractor', () => {
const extract = boundsExtractor()
it('extracts a single bounds object widget', () => {
const bounds = { x: 10, y: 20, width: 100, height: 200 }
const widgets = [widget('crop', bounds)]
expect(extract(widgets, undefined)).toEqual(bounds)
})
it('matches bounds widget by outputName', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [widget('other', 'text'), widget('crop', bounds)]
expect(extract(widgets, 'crop')).toEqual(bounds)
})
it('assembles bounds from individual x/y/width/height widgets', () => {
const widgets = [
widget('x', 10),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toEqual({
x: 10,
y: 20,
width: 100,
height: 200
})
})
it('returns undefined when some bound components are missing', () => {
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when bound components have wrong types', () => {
const widgets = [
widget('x', '10'),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
it('rejects partial bounds objects', () => {
const partial = { x: 10, y: 20 }
const widgets = [widget('crop', partial)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('prefers single bounds object over individual widgets', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [
widget('crop', bounds),
widget('x', 99),
widget('y', 99),
widget('width', 99),
widget('height', 99)
]
expect(extract(widgets, undefined)).toEqual(bounds)
})
})

View File

@@ -1,80 +0,0 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
outputName: string | undefined
) => T | undefined
export function useUpstreamValue<T>(
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
extractValue: ValueExtractor<T>
) {
const canvasStore = useCanvasStore()
const widgetValueStore = useWidgetValueStore()
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)
})
}
export function singleValueExtractor<T>(
isValid: (value: unknown) => value is T
): ValueExtractor<T> {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
if (matched && isValid(matched.value)) return matched.value
}
const validValues = widgets.map((w) => w.value).filter(isValid)
return validValues.length === 1 ? validValues[0] : undefined
}
}
function isBoundsObject(value: unknown): value is Bounds {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return (
typeof v.x === 'number' &&
typeof v.y === 'number' &&
typeof v.width === 'number' &&
typeof v.height === 'number'
)
}
export function boundsExtractor(): ValueExtractor<Bounds> {
const single = singleValueExtractor(isBoundsObject)
return (widgets, outputName) => {
const singleResult = single(widgets, outputName)
if (singleResult) return singleResult
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
const getNum = (name: string): number | undefined => {
const w = widgets.find((w) => w.name === name)
return typeof w?.value === 'number' ? w.value : undefined
}
const x = getNum('x')
const y = getNum('y')
const width = getNum('width')
const height = getNum('height')
if (
x !== undefined &&
y !== undefined &&
width !== undefined &&
height !== undefined
) {
return { x, y, width, height }
}
return undefined
}
}

View 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
})
}
}
}
})

View 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)])
}
})

View 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)
})
}
})

View File

@@ -45,8 +45,7 @@ import { LiteGraph, SubgraphNode } from './litegraph'
import {
alignOutsideContainer,
alignToContainer,
createBounds,
snapPoint
createBounds
} from './measure'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
@@ -2595,18 +2594,7 @@ export class LGraph
// configure nodes afterwards so they can reach each other
for (const [id, nodeData] of nodeDataMap) {
const node = this.getNodeById(id)
node?.configure(nodeData)
if (LiteGraph.alwaysSnapToGrid && node) {
const snapTo = this.getSnapToGridSize()
if (node.snapToGrid(snapTo)) {
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
// This reassignment triggers the pos setter to sync to the Vue layout store
node.pos = [node.pos[0], node.pos[1]]
}
snapPoint(node.size, snapTo, 'ceil')
}
this.getNodeById(id)?.configure(nodeData)
}
}

View File

@@ -2959,11 +2959,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Enforce minimum size
const min = node.computeSize()
if (this._snapToGrid) {
// Previously newBounds.size is snapped with 'round'
// Now the minimum size is snapped with 'ceil' to avoid clipping
snapPoint(min, this._snapToGrid, 'ceil')
}
if (newBounds.width < min[0]) {
// If resizing from left, adjust position to maintain right edge
if (resizeDirection.includes('W')) {

View File

@@ -130,34 +130,6 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
expect(point3).toEqual([20, 20])
})
test('snapPoint correctly snaps points to grid using ceil', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'ceil')).toBe(true)
expect(point).toEqual([15, 20])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'ceil')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'ceil')).toBe(true)
expect(point3).toEqual([20, -10])
})
test('snapPoint correctly snaps points to grid using floor', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'floor')).toBe(true)
expect(point).toEqual([10, 15])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'floor')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'floor')).toBe(true)
expect(point3).toEqual([10, -20])
})
test('createBounds correctly creates bounding box', ({ expect }) => {
const objects = [
{ boundingRect: [0, 0, 10, 10] as Rect },

View File

@@ -351,15 +351,11 @@ export function createBounds(
* @returns `true` if snapTo is truthy, otherwise `false`
* @remarks `NaN` propagates through this function and does not affect return value.
*/
export function snapPoint(
pos: Point | Rect,
snapTo: number,
method: 'round' | 'ceil' | 'floor' = 'round'
): boolean {
export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
if (!snapTo) return false
pos[0] = snapTo * Math[method](pos[0] / snapTo)
pos[1] = snapTo * Math[method](pos[1] / snapTo)
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo)
return true
}

View File

@@ -37,11 +37,8 @@ describe('useMinimapGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
const linksMap = new Map([[1, createMockLLink({ id: 1 })]])
mockGraph = createMockLGraph({
id: 'test-graph-123',
_version: 0,
_links: linksMap,
_nodes: [
createMockLGraphNode({ id: '1', pos: [100, 100], size: [150, 80] }),
createMockLGraphNode({ id: '2', pos: [300, 200], size: [120, 60] })
@@ -202,53 +199,24 @@ describe('useMinimapGraph', () => {
expect(graphManager.updateFlags.value.nodes).toBe(true)
})
it('should detect connection changes via graph version', () => {
it('should detect connection changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Increment graph version (simulates add/remove node/link)
mockGraph._version++
// Change connections
mockGraph.links = createMockLinks([
createMockLLink({ id: 1 }),
createMockLLink({ id: 2 })
])
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should detect connection changes via link count', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Add a link to the backing Map
const newLink = createMockLLink({ id: 2 })
mockGraph._links.set(2, newLink)
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(true)
expect(graphManager.updateFlags.value.connections).toBe(true)
})
it('should not detect connection changes when version and count unchanged', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Cache initial state
graphManager.checkForChanges()
// Reset flags
graphManager.updateFlags.value.connections = false
// No changes to version or link count
const hasChanges = graphManager.checkForChanges()
expect(hasChanges).toBe(false)
expect(graphManager.updateFlags.value.connections).toBe(false)
})
it('should handle node removal in callbacks', () => {
const originalOnNodeRemoved = vi.fn()
mockGraph.onNodeRemoved = originalOnNodeRemoved

View File

@@ -12,7 +12,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { MinimapDataSourceFactory } from '../data/MinimapDataSourceFactory'
import type { IMinimapDataSource, UpdateFlags } from '../types'
import type { UpdateFlags } from '../types'
interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
@@ -26,8 +26,7 @@ export function useMinimapGraph(
onGraphChanged: () => void
) {
const nodeStatesCache = new Map<NodeId, string>()
let lastLinkCount = 0
let lastGraphVersion = -1
const linksCache = ref<string>('')
const lastNodeCount = ref(0)
const updateFlags = ref<UpdateFlags>({
bounds: false,
@@ -36,13 +35,6 @@ export function useMinimapGraph(
viewport: false
})
// Reusable Set for node ID cleanup (avoids allocation per frame)
const currentNodeIds = new Set<NodeId>()
// Cached data source instance — invalidated when graph changes
let cachedDataSource: IMinimapDataSource | null = null
let cachedDataSourceGraph: LGraph | null = null
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
@@ -122,20 +114,6 @@ export function useMinimapGraph(
originalCallbacksMap.delete(g.id)
}
function getDataSource(g: LGraph): IMinimapDataSource {
if (cachedDataSource && cachedDataSourceGraph === g) {
return cachedDataSource
}
nodeStatesCache.clear()
currentNodeIds.clear()
lastNodeCount.value = 0
lastLinkCount = 0
lastGraphVersion = -1
cachedDataSource = MinimapDataSourceFactory.create(g)
cachedDataSourceGraph = g
return cachedDataSource
}
const checkForChangesInternal = () => {
const g = graph.value
if (!g) return false
@@ -144,7 +122,8 @@ export function useMinimapGraph(
let positionChanged = false
let connectionChanged = false
const dataSource = getDataSource(g)
// Use unified data source for change detection
const dataSource = MinimapDataSourceFactory.create(g)
// Check for node count changes
const currentNodeCount = dataSource.getNodeCount()
@@ -165,11 +144,8 @@ export function useMinimapGraph(
}
}
// Clean up removed nodes from cache (reuse Set to avoid allocation)
currentNodeIds.clear()
for (const node of nodes) {
currentNodeIds.add(node.id)
}
// Clean up removed nodes from cache
const currentNodeIds = new Set(nodes.map((n) => n.id))
for (const [nodeId] of nodeStatesCache) {
if (!currentNodeIds.has(nodeId)) {
nodeStatesCache.delete(nodeId)
@@ -177,17 +153,11 @@ export function useMinimapGraph(
}
}
// Detect connection changes via graph version + link count
// (avoids JSON.stringify of all links every frame)
const currentVersion = g._version
const currentLinkCount = g._links?.size ?? 0
if (
currentVersion !== lastGraphVersion ||
currentLinkCount !== lastLinkCount
) {
// TODO: update when Layoutstore tracks links
const currentLinks = JSON.stringify(g.links || {})
if (currentLinks !== linksCache.value) {
connectionChanged = true
lastGraphVersion = currentVersion
lastLinkCount = currentLinkCount
linksCache.value = currentLinks
}
if (structureChanged || positionChanged) {
@@ -214,17 +184,13 @@ export function useMinimapGraph(
const destroy = () => {
cleanupEventListeners()
api.removeEventListener('graphChanged', handleGraphChangedThrottled)
clearCache()
nodeStatesCache.clear()
}
const clearCache = () => {
nodeStatesCache.clear()
currentNodeIds.clear()
lastLinkCount = 0
lastGraphVersion = -1
linksCache.value = ''
lastNodeCount.value = 0
cachedDataSource = null
cachedDataSourceGraph = null
}
return {

View File

@@ -120,11 +120,7 @@ import {
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
@@ -301,14 +297,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
@@ -317,7 +305,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec
}

View File

@@ -57,7 +57,7 @@ export function useNodeSnap() {
if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height]
if (snapPoint(sizeArray, gridSizeValue, 'ceil')) {
if (snapPoint(sizeArray, gridSizeValue)) {
return { width: sizeArray[0], height: sizeArray[1] }
}
return { ...size }

View File

@@ -1,6 +1,4 @@
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces'
import type { Point } from '@/renderer/core/layout/types'
import {
@@ -17,7 +15,7 @@ interface Positioned {
size: LGPoint
}
function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) {
function unprojectPosSize(item: Positioned, anchor: Point) {
const c = unprojectBounds(
{
x: item.pos[0],
@@ -32,14 +30,6 @@ function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) {
item.pos[1] = c.y
item.size[0] = c.width
item.size[1] = c.height
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(item.pos, snapTo, 'round')
snapPoint(item.size, snapTo, 'ceil')
}
}
}
/**
@@ -70,18 +60,6 @@ export function ensureCorrectLayoutScale(
const anchor = getGraphRenderAnchor(graph)
const applySnap = (
pos: [number, number],
method: 'round' | 'ceil' | 'floor' = 'round'
) => {
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(pos, snapTo, method)
}
}
}
for (const node of graph.nodes) {
const c = unprojectBounds(
{
@@ -97,9 +75,6 @@ export function ensureCorrectLayoutScale(
node.pos[1] = c.y
node.size[0] = c.width
node.size[1] = c.height
applySnap(node.pos)
applySnap(node.size, 'ceil')
}
for (const reroute of graph.reroutes.values()) {
@@ -109,11 +84,10 @@ export function ensureCorrectLayoutScale(
RENDER_SCALE_FACTOR
)
reroute.pos = [p.x, p.y]
applySnap(reroute.pos)
}
for (const group of graph.groups) {
unprojectPosSize(group, anchor, graph)
unprojectPosSize(group, anchor)
}
if ('inputNode' in graph && 'outputNode' in graph) {
@@ -122,7 +96,7 @@ export function ensureCorrectLayoutScale(
graph.outputNode as SubgraphOutputNode
]) {
if (ioNode) {
unprojectPosSize(ioNode, anchor, graph)
unprojectPosSize(ioNode, anchor)
}
}
}

View File

@@ -17,7 +17,6 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
@@ -1310,14 +1309,10 @@ export class ComfyApp {
console.error(error)
return
}
const snapTo = LiteGraph.alwaysSnapToGrid
? this.rootGraph.getSnapToGridSize()
: 0
forEachNode(this.rootGraph, (node) => {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
snapPoint(size, snapTo, 'ceil')
node.setSize(size)
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend

View 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' }
)
}

View File

@@ -384,15 +384,6 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
quickRegister('lama', 'LaMa', 'lama_model')
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', 'model')
// Inpaint models (comfyui-inpaint-nodes)
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
}
return {

169
src/types/extensionV2.ts Normal file
View 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>
}

View File

@@ -38,11 +38,6 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export interface LinkedUpstreamInfo {
nodeId: string
outputName?: string
}
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O extends IWidgetOptions = IWidgetOptions
@@ -82,8 +77,6 @@ export interface SimplifiedWidget<
tooltip?: string
controlWidget?: SafeControlWidget
linkedUpstream?: LinkedUpstreamInfo
}
export interface SimplifiedControlWidget<