mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 13:11:01 +00:00
Compare commits
3 Commits
perf/minim
...
extension-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab3f218e2 | ||
|
|
2f8fe0013b | ||
|
|
fa2f79ad3a |
@@ -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
531
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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) : ''
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
384
src/services/extensionV2Service.ts
Normal file
384
src/services/extensionV2Service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Extension V2 Service
|
||||
*
|
||||
* Manages extension lifecycle: scope creation, handle construction,
|
||||
* reactive dispatch. The extension system watches the ECS World for
|
||||
* entity creation/removal and auto-mounts/unmounts extension scopes.
|
||||
*
|
||||
* Pattern mirrors Vue's setupStatefulComponent (component.ts:829-927):
|
||||
* - scope.run() activates the EffectScope (like setCurrentInstance)
|
||||
* - pauseTracking() prevents accidental deps during setup
|
||||
* - All watches/effects created inside scope.run() are captured
|
||||
* - scope.stop() on entity removal cleans up everything
|
||||
*
|
||||
* See decisions/D3.5-reactive-dispatch-and-scope-alignment.md
|
||||
*/
|
||||
|
||||
import {
|
||||
EffectScope,
|
||||
onScopeDispose,
|
||||
pauseTracking,
|
||||
resetTracking,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
// These modules don't exist yet — they will be created as part of ADR 0008.
|
||||
import { useWorld } from '@/ecs/world'
|
||||
import { dispatch } from '@/ecs/commands'
|
||||
import {
|
||||
Position,
|
||||
Dimensions,
|
||||
NodeVisual,
|
||||
NodeType,
|
||||
Execution,
|
||||
WidgetValue,
|
||||
WidgetIdentity,
|
||||
WidgetContainer,
|
||||
Connectivity,
|
||||
SlotIdentity,
|
||||
LoadedFromWorkflow
|
||||
} from '@/ecs/components'
|
||||
import type {
|
||||
NodeEntityId,
|
||||
WidgetEntityId,
|
||||
SlotEntityId
|
||||
} from '@/ecs/entityIds'
|
||||
|
||||
import type {
|
||||
NodeExtensionOptions,
|
||||
NodeHandle,
|
||||
WidgetExtensionOptions,
|
||||
WidgetHandle,
|
||||
WidgetOptions,
|
||||
SlotInfo,
|
||||
Point,
|
||||
Size
|
||||
} from '@/types/extensionV2'
|
||||
|
||||
// ─── Scope Registry ──────────────────────────────────────────────────
|
||||
// One EffectScope per extension+entity pair. Disposed when the entity is
|
||||
// removed from the World (detected by the reactive mount watcher).
|
||||
|
||||
const scopeRegistry = new Map<string, EffectScope>()
|
||||
|
||||
function getOrCreateScope(
|
||||
extensionName: string,
|
||||
entityId: number
|
||||
): EffectScope {
|
||||
const key = `${extensionName}:${entityId}`
|
||||
let scope = scopeRegistry.get(key)
|
||||
if (!scope) {
|
||||
scope = new EffectScope(true)
|
||||
scopeRegistry.set(key, scope)
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
function stopScope(extensionName: string, entityId: number): void {
|
||||
const key = `${extensionName}:${entityId}`
|
||||
const scope = scopeRegistry.get(key)
|
||||
if (scope) {
|
||||
scope.stop()
|
||||
scopeRegistry.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WidgetHandle ────────────────────────────────────────────────────
|
||||
|
||||
function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
|
||||
const world = useWorld()
|
||||
|
||||
return {
|
||||
entityId: widgetId,
|
||||
get name() {
|
||||
return world.getComponent(widgetId, WidgetIdentity).name
|
||||
},
|
||||
get widgetType() {
|
||||
return world.getComponent(widgetId, WidgetIdentity).type
|
||||
},
|
||||
|
||||
getValue<T = unknown>(): T {
|
||||
return world.getComponent(widgetId, WidgetValue).value as T
|
||||
},
|
||||
setValue(value: unknown) {
|
||||
dispatch({ type: 'SetWidgetValue', widgetId, value })
|
||||
},
|
||||
|
||||
isHidden() {
|
||||
return (
|
||||
world.getComponent(widgetId, WidgetValue).options?.hidden ?? false
|
||||
)
|
||||
},
|
||||
setHidden(hidden: boolean) {
|
||||
dispatch({
|
||||
type: 'SetWidgetOption',
|
||||
widgetId,
|
||||
key: 'hidden',
|
||||
value: hidden
|
||||
})
|
||||
},
|
||||
getOptions(): WidgetOptions {
|
||||
return world.getComponent(widgetId, WidgetValue).options ?? {}
|
||||
},
|
||||
setOption(key: string, value: unknown) {
|
||||
dispatch({ type: 'SetWidgetOption', widgetId, key, value })
|
||||
},
|
||||
setLabel(label: string) {
|
||||
dispatch({ type: 'SetWidgetLabel', widgetId, label })
|
||||
},
|
||||
|
||||
on(event: string, fn: Function) {
|
||||
if (event === 'change') {
|
||||
watch(
|
||||
() => world.getComponent(widgetId, WidgetValue).value,
|
||||
(newVal, oldVal) => fn(newVal, oldVal)
|
||||
)
|
||||
}
|
||||
if (event === 'removed') {
|
||||
onScopeDispose(() => fn())
|
||||
}
|
||||
},
|
||||
|
||||
setSerializeValue(fn) {
|
||||
dispatch({ type: 'SetWidgetSerializer', widgetId, serializer: fn })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NodeHandle ──────────────────────────────────────────────────────
|
||||
|
||||
function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
|
||||
const world = useWorld()
|
||||
|
||||
return {
|
||||
entityId: nodeId,
|
||||
get type() {
|
||||
return world.getComponent(nodeId, NodeType).type
|
||||
},
|
||||
get comfyClass() {
|
||||
return world.getComponent(nodeId, NodeType).comfyClass
|
||||
},
|
||||
|
||||
// Reads — direct World queries
|
||||
getPosition(): Point {
|
||||
return world.getComponent(nodeId, Position).pos
|
||||
},
|
||||
getSize(): Size {
|
||||
return world.getComponent(nodeId, Dimensions).size
|
||||
},
|
||||
getTitle() {
|
||||
return world.getComponent(nodeId, NodeVisual).title
|
||||
},
|
||||
getMode() {
|
||||
return world.getComponent(nodeId, Execution).mode
|
||||
},
|
||||
getProperty<T = unknown>(key: string): T | undefined {
|
||||
return world.getComponent(nodeId, NodeType).properties?.[key] as T
|
||||
},
|
||||
getProperties() {
|
||||
return { ...world.getComponent(nodeId, NodeType).properties }
|
||||
},
|
||||
isSelected() {
|
||||
return world.getComponent(nodeId, NodeVisual).selected ?? false
|
||||
},
|
||||
|
||||
// Writes — command dispatches
|
||||
setPosition(pos: Point) {
|
||||
dispatch({ type: 'MoveNode', nodeId, pos })
|
||||
},
|
||||
setSize(size: Size) {
|
||||
dispatch({ type: 'ResizeNode', nodeId, size })
|
||||
},
|
||||
setTitle(title: string) {
|
||||
dispatch({ type: 'SetNodeVisual', nodeId, patch: { title } })
|
||||
},
|
||||
setMode(mode) {
|
||||
dispatch({ type: 'SetNodeMode', nodeId, mode })
|
||||
},
|
||||
setProperty(key: string, value: unknown) {
|
||||
dispatch({ type: 'SetNodeProperty', nodeId, key, value })
|
||||
},
|
||||
|
||||
// Widgets
|
||||
widget(name: string) {
|
||||
const container = world.getComponent(nodeId, WidgetContainer)
|
||||
const widgetId = container.widgetIds.find((id) => {
|
||||
return world.getComponent(id, WidgetIdentity).name === name
|
||||
})
|
||||
return widgetId ? createWidgetHandle(widgetId) : undefined
|
||||
},
|
||||
widgets() {
|
||||
const container = world.getComponent(nodeId, WidgetContainer)
|
||||
return container.widgetIds.map(createWidgetHandle)
|
||||
},
|
||||
addWidget(type, name, defaultValue, options) {
|
||||
const widgetId = dispatch({
|
||||
type: 'CreateWidget',
|
||||
parentNodeId: nodeId,
|
||||
widgetType: type,
|
||||
name,
|
||||
defaultValue,
|
||||
options
|
||||
})
|
||||
return createWidgetHandle(widgetId)
|
||||
},
|
||||
|
||||
// Slots
|
||||
inputs() {
|
||||
const conn = world.getComponent(nodeId, Connectivity)
|
||||
return conn.inputSlotIds.map((slotId) => {
|
||||
const slot = world.getComponent(slotId, SlotIdentity)
|
||||
return {
|
||||
entityId: slotId,
|
||||
name: slot.name,
|
||||
type: slot.type,
|
||||
direction: 'input' as const,
|
||||
nodeEntityId: nodeId
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
outputs() {
|
||||
const conn = world.getComponent(nodeId, Connectivity)
|
||||
return conn.outputSlotIds.map((slotId) => {
|
||||
const slot = world.getComponent(slotId, SlotIdentity)
|
||||
return {
|
||||
entityId: slotId,
|
||||
name: slot.name,
|
||||
type: slot.type,
|
||||
direction: 'output' as const,
|
||||
nodeEntityId: nodeId
|
||||
} satisfies SlotInfo
|
||||
})
|
||||
},
|
||||
|
||||
// Events — backed by World component watches
|
||||
on(event: string, fn: Function) {
|
||||
if (event === 'positionChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Position).pos,
|
||||
(pos) => fn(pos)
|
||||
)
|
||||
} else if (event === 'sizeChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Dimensions).size,
|
||||
(s) => fn(s)
|
||||
)
|
||||
} else if (event === 'modeChanged') {
|
||||
watch(
|
||||
() => world.getComponent(nodeId, Execution).mode,
|
||||
(m) => fn(m)
|
||||
)
|
||||
} else if (event === 'executed') {
|
||||
world.onSystemEvent(nodeId, 'executed', fn)
|
||||
} else if (event === 'connected') {
|
||||
world.onSystemEvent(nodeId, 'connected', fn)
|
||||
} else if (event === 'disconnected') {
|
||||
world.onSystemEvent(nodeId, 'disconnected', fn)
|
||||
} else if (event === 'configured') {
|
||||
world.onSystemEvent(nodeId, 'configured', fn)
|
||||
} else if (event === 'removed') {
|
||||
onScopeDispose(() => fn())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extension Registry ──────────────────────────────────────────────
|
||||
|
||||
const nodeExtensions: NodeExtensionOptions[] = []
|
||||
const widgetExtensions: WidgetExtensionOptions[] = []
|
||||
|
||||
export function defineNodeExtension(options: NodeExtensionOptions): void {
|
||||
nodeExtensions.push(options)
|
||||
}
|
||||
|
||||
export function defineWidgetExtension(options: WidgetExtensionOptions): void {
|
||||
widgetExtensions.push(options)
|
||||
}
|
||||
|
||||
// ─── Reactive Mount System ───────────────────────────────────────────
|
||||
// Watches the World for entity creation/removal. When a NodeType
|
||||
// component appears, extensions are mounted for that entity. When it
|
||||
// disappears, all extension scopes for that entity are stopped.
|
||||
//
|
||||
// This replaces the imperative dispatchNodeCreated/dispatchLoadedGraphNode
|
||||
// pattern. The World is the single source of truth — if an entity
|
||||
// exists, its extensions are mounted.
|
||||
|
||||
/**
|
||||
* Mount extensions for a newly detected node entity.
|
||||
*
|
||||
* Follows Vue's setupStatefulComponent pattern:
|
||||
* 1. scope.run() activates the EffectScope
|
||||
* 2. pauseTracking() prevents accidental dependency tracking
|
||||
* 3. Extension hook runs — explicit watches via node.on() are captured
|
||||
* 4. resetTracking() restores tracking state
|
||||
*/
|
||||
function mountExtensionsForNode(nodeId: NodeEntityId): void {
|
||||
const world = useWorld()
|
||||
const comfyClass = world.getComponent(nodeId, NodeType).comfyClass
|
||||
const isLoaded = world.hasComponent(nodeId, LoadedFromWorkflow)
|
||||
|
||||
for (const ext of nodeExtensions) {
|
||||
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
|
||||
|
||||
const hook = isLoaded ? ext.loadedGraphNode : ext.nodeCreated
|
||||
if (!hook) continue
|
||||
|
||||
const scope = getOrCreateScope(ext.name, nodeId)
|
||||
scope.run(() => {
|
||||
pauseTracking()
|
||||
try {
|
||||
hook(createNodeHandle(nodeId))
|
||||
} finally {
|
||||
resetTracking()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount all extension scopes for a removed node entity.
|
||||
* scope.stop() disposes all watches, computed, and onScopeDispose
|
||||
* callbacks created during the extension's setup.
|
||||
*/
|
||||
function unmountExtensionsForNode(nodeId: NodeEntityId): void {
|
||||
for (const ext of nodeExtensions) {
|
||||
stopScope(ext.name, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the reactive extension mount system.
|
||||
*
|
||||
* Called once during app initialization. Watches the World's entity list
|
||||
* and auto-mounts/unmounts extensions as entities appear/disappear.
|
||||
*
|
||||
* This means no code path (add node, paste, load workflow, undo, CRDT
|
||||
* sync) needs to manually call a dispatch function — the World is the
|
||||
* single source of truth.
|
||||
*/
|
||||
export function startExtensionSystem(): void {
|
||||
const world = useWorld()
|
||||
|
||||
watch(
|
||||
() => world.queryAll(NodeType),
|
||||
(currentIds, previousIds) => {
|
||||
const prev = new Set(previousIds ?? [])
|
||||
const curr = new Set(currentIds)
|
||||
|
||||
for (const nodeId of currentIds) {
|
||||
if (!prev.has(nodeId)) {
|
||||
mountExtensionsForNode(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of previousIds ?? []) {
|
||||
if (!curr.has(nodeId)) {
|
||||
unmountExtensionsForNode(nodeId)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
}
|
||||
@@ -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
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>
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user