mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
live preview - String length and concatenate node
This commit is contained in:
344
src/composables/useLivePreview.ts
Normal file
344
src/composables/useLivePreview.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface PropagationOptions {
|
||||
/**
|
||||
* Find output by name instead of index
|
||||
*/
|
||||
outputName?: string
|
||||
|
||||
/**
|
||||
* Explicitly specify output index (default: 0)
|
||||
*/
|
||||
outputIndex?: number
|
||||
|
||||
/**
|
||||
* Whether to call node.setOutputData (default: false)
|
||||
*/
|
||||
setOutputData?: boolean
|
||||
|
||||
/**
|
||||
* Whether to update target widget values (default: true)
|
||||
*/
|
||||
updateWidget?: boolean
|
||||
|
||||
/**
|
||||
* Whether to call widget.callback after updating (default: false)
|
||||
*/
|
||||
callWidgetCallback?: boolean
|
||||
|
||||
/**
|
||||
* Whether to call targetNode.onExecuted (default: false)
|
||||
*/
|
||||
callOnExecuted?: boolean
|
||||
|
||||
/**
|
||||
* Custom function to build the message for onExecuted
|
||||
*/
|
||||
messageBuilder?: (
|
||||
targetNode: LGraphNode,
|
||||
value: TWidgetValue,
|
||||
link: any
|
||||
) => any
|
||||
|
||||
/**
|
||||
* Custom handlers for specific node types
|
||||
* Return true if handled, false to continue with default behavior
|
||||
*/
|
||||
customHandlers?: Map<
|
||||
string,
|
||||
(node: LGraphNode, value: TWidgetValue, link: any) => boolean
|
||||
>
|
||||
|
||||
/**
|
||||
* Enable reentry protection (default: true)
|
||||
*/
|
||||
preventReentry?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculator function type for live preview nodes
|
||||
* Takes input values and returns the computed output value
|
||||
*/
|
||||
type LivePreviewCalculator = (inputValues: any[]) => TWidgetValue
|
||||
|
||||
/**
|
||||
* Configuration for setting up a live preview node
|
||||
*/
|
||||
interface LivePreviewNodeConfig {
|
||||
/**
|
||||
* The calculator function that computes output from inputs
|
||||
*/
|
||||
calculator: LivePreviewCalculator
|
||||
|
||||
/**
|
||||
* Optional output index (default: 0)
|
||||
*/
|
||||
outputIndex?: number
|
||||
|
||||
/**
|
||||
* Optional propagation options to use when propagating the result
|
||||
*/
|
||||
propagationOptions?: Omit<PropagationOptions, 'outputIndex' | 'setOutputData'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing live preview functionality in ComfyUI nodes
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a node extension:
|
||||
* const { setupLivePreviewNode, propagateLivePreview } = useLivePreview()
|
||||
*
|
||||
* // For computation nodes:
|
||||
* setupLivePreviewNode(node, {
|
||||
* calculator: (inputs) => {
|
||||
* const [a, b] = inputs
|
||||
* return a + b
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // For simple propagation:
|
||||
* propagateLivePreview(node, value, {
|
||||
* updateWidget: true,
|
||||
* callOnExecuted: true
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
const propagationFlags = new WeakMap<LGraphNode, Set<string>>()
|
||||
const nodeCalculators = new WeakMap<LGraphNode, LivePreviewNodeConfig>()
|
||||
|
||||
export function useLivePreview() {
|
||||
function getPropagationKey(outputIndex: number): string {
|
||||
return `propagating_${outputIndex}`
|
||||
}
|
||||
|
||||
function isNodePropagating(node: LGraphNode, outputIndex: number): boolean {
|
||||
const flags = propagationFlags.get(node)
|
||||
return flags?.has(getPropagationKey(outputIndex)) ?? false
|
||||
}
|
||||
|
||||
function setNodePropagating(
|
||||
node: LGraphNode,
|
||||
outputIndex: number,
|
||||
value: boolean
|
||||
): void {
|
||||
if (!propagationFlags.has(node)) {
|
||||
propagationFlags.set(node, new Set())
|
||||
}
|
||||
const flags = propagationFlags.get(node)!
|
||||
const key = getPropagationKey(outputIndex)
|
||||
|
||||
if (value) {
|
||||
flags.add(key)
|
||||
} else {
|
||||
flags.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function collectNodeInputValues(node: LGraphNode): any[] {
|
||||
const inputValues: any[] = []
|
||||
const graph = node.graph as LGraph
|
||||
|
||||
if (!graph || !node.inputs) {
|
||||
return inputValues
|
||||
}
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (input.link != null) {
|
||||
const link = graph.links[input.link]
|
||||
if (link) {
|
||||
const sourceNode = graph.getNodeById(link.origin_id)
|
||||
if (sourceNode && sourceNode.getOutputData) {
|
||||
const outputData = sourceNode.getOutputData(link.origin_slot)
|
||||
inputValues.push(outputData)
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
} else if (input.widget) {
|
||||
const widget = node.widgets?.find((w) => w.name === input.widget?.name)
|
||||
inputValues.push(widget?.value)
|
||||
} else {
|
||||
inputValues.push(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return inputValues
|
||||
}
|
||||
|
||||
function triggerNodeRecalculation(node: LGraphNode): void {
|
||||
const config = nodeCalculators.get(node)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const inputValues = collectNodeInputValues(node)
|
||||
|
||||
const hasValidInputs = inputValues.some((v) => v !== undefined)
|
||||
if (!hasValidInputs) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = config.calculator(inputValues)
|
||||
if (result !== undefined) {
|
||||
propagateLivePreview(node, result, {
|
||||
outputIndex: config.outputIndex ?? 0,
|
||||
setOutputData: true,
|
||||
...config.propagationOptions
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error calculating live preview for node ${node.type}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function propagateLivePreview(
|
||||
sourceNode: LGraphNode,
|
||||
value: TWidgetValue,
|
||||
options: PropagationOptions = {}
|
||||
): void {
|
||||
const {
|
||||
outputName,
|
||||
outputIndex: explicitOutputIndex,
|
||||
setOutputData = false,
|
||||
updateWidget = true,
|
||||
callWidgetCallback = false,
|
||||
callOnExecuted = false,
|
||||
messageBuilder,
|
||||
customHandlers,
|
||||
preventReentry = true
|
||||
} = options
|
||||
|
||||
let outputIndex = explicitOutputIndex ?? 0
|
||||
|
||||
if (outputName && sourceNode.outputs) {
|
||||
const foundIndex = sourceNode.outputs.findIndex(
|
||||
(output) => output.name === outputName
|
||||
)
|
||||
if (foundIndex >= 0) {
|
||||
outputIndex = foundIndex
|
||||
}
|
||||
}
|
||||
|
||||
if (preventReentry && isNodePropagating(sourceNode, outputIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (preventReentry) {
|
||||
setNodePropagating(sourceNode, outputIndex, true)
|
||||
}
|
||||
|
||||
try {
|
||||
if (setOutputData && sourceNode.setOutputData && value !== undefined) {
|
||||
sourceNode.setOutputData(outputIndex, value as any)
|
||||
}
|
||||
|
||||
const output = sourceNode.outputs?.[outputIndex]
|
||||
if (!output || !output.links || output.links.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const graph = sourceNode.graph as LGraph
|
||||
if (!graph) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = graph.links[linkId]
|
||||
if (!link) {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetNode = graph.getNodeById(link.target_id)
|
||||
if (!targetNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (customHandlers?.has(targetNode.type)) {
|
||||
const handler = customHandlers.get(targetNode.type)!
|
||||
const handled = handler(targetNode, value, link)
|
||||
if (handled) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (updateWidget) {
|
||||
const targetInput = targetNode.inputs?.[link.target_slot]
|
||||
if (targetInput?.widget) {
|
||||
const targetWidget = targetNode.widgets?.find(
|
||||
(w: IBaseWidget) => w.name === targetInput.widget?.name
|
||||
)
|
||||
|
||||
if (targetWidget) {
|
||||
targetWidget.value = value
|
||||
|
||||
if (callWidgetCallback && targetWidget.callback) {
|
||||
targetWidget.callback(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasCalculator = nodeCalculators.has(targetNode)
|
||||
|
||||
if (hasCalculator) {
|
||||
triggerNodeRecalculation(targetNode)
|
||||
continue
|
||||
}
|
||||
|
||||
if (callOnExecuted && targetNode.onExecuted) {
|
||||
const message = messageBuilder
|
||||
? messageBuilder(targetNode, value, link)
|
||||
: { text: [value] }
|
||||
|
||||
targetNode.onExecuted(message)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (preventReentry) {
|
||||
setNodePropagating(sourceNode, outputIndex, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupLivePreviewNode(
|
||||
node: LGraphNode,
|
||||
config: LivePreviewNodeConfig
|
||||
): void {
|
||||
nodeCalculators.set(node, config)
|
||||
|
||||
const originalOnExecuted = node.onExecuted
|
||||
node.onExecuted = function (message: any) {
|
||||
if (originalOnExecuted) {
|
||||
originalOnExecuted.call(this, message)
|
||||
}
|
||||
|
||||
if (message.text && Array.isArray(message.text)) {
|
||||
const result = config.calculator(message.text)
|
||||
if (result !== undefined) {
|
||||
propagateLivePreview(this, result, {
|
||||
outputIndex: config.outputIndex ?? 0,
|
||||
setOutputData: true,
|
||||
...config.propagationOptions
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
propagateLivePreview,
|
||||
setupLivePreviewNode
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import './matchType'
|
||||
import './nodeTemplates'
|
||||
import './noteNode'
|
||||
import './previewAny'
|
||||
import './stringOperations'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
import './saveMesh'
|
||||
|
||||
58
src/extensions/core/stringOperations.ts
Normal file
58
src/extensions/core/stringOperations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
|
||||
const { setupLivePreviewNode } = useLivePreview()
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.StringLength',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === 'StringLength') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.call(this)
|
||||
}
|
||||
|
||||
// Set up live preview with calculator
|
||||
setupLivePreviewNode(this, {
|
||||
calculator: (inputs) => {
|
||||
const inputString = inputs[0]
|
||||
if (inputString == null) return undefined
|
||||
return String(inputString).length
|
||||
},
|
||||
propagationOptions: {
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.StringConcatenate',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === 'StringConcatenate') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) {
|
||||
onNodeCreated.call(this)
|
||||
}
|
||||
|
||||
// Set up live preview with calculator
|
||||
setupLivePreviewNode(this, {
|
||||
calculator: (inputs) => {
|
||||
const [string_a, string_b, delimiter] = inputs
|
||||
if (string_a == null && string_b == null) return undefined
|
||||
return [string_a ?? '', string_b ?? ''].join(delimiter || '')
|
||||
},
|
||||
propagationOptions: {
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -67,7 +67,9 @@ import type {
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
import { st } from '@/i18n'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
@@ -83,12 +85,16 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
const { propagateLivePreview } = useLivePreview()
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
}
|
||||
|
||||
const { nodeData } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
function handleWidgetPointerEvent(event: PointerEvent) {
|
||||
@@ -113,6 +119,24 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
nodeType.value
|
||||
)
|
||||
|
||||
function propagateToDownstreamVue(
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
value: WidgetValue
|
||||
): void {
|
||||
const lgNode = nodeManager.value?.getNode(sourceNodeId)
|
||||
if (!lgNode || !value) {
|
||||
return
|
||||
}
|
||||
|
||||
propagateLivePreview(lgNode, value, {
|
||||
outputName: widgetName,
|
||||
updateWidget: true,
|
||||
callWidgetCallback: false,
|
||||
callOnExecuted: false
|
||||
})
|
||||
}
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
@@ -170,6 +194,10 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (widget.type !== 'asset') {
|
||||
widget.callback?.(value)
|
||||
}
|
||||
|
||||
if (nodeData?.id && nodeManager.value) {
|
||||
propagateToDownstreamVue(nodeData.id, widget.name, value)
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
|
||||
@@ -4,6 +4,10 @@ import { isStringInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import { useLivePreview } from '@/composables/useLivePreview'
|
||||
|
||||
const { propagateLivePreview } = useLivePreview()
|
||||
|
||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
||||
|
||||
@@ -119,6 +123,22 @@ export const useStringWidget = () => {
|
||||
const defaultVal = inputSpec.default ?? ''
|
||||
const multiline = inputSpec.multiline
|
||||
|
||||
const propagateCallback = (value: WidgetValue) => {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simple propagation: just send the value downstream
|
||||
// - Nodes with calculators will automatically recalculate
|
||||
// - Passive nodes (like PreviewAny) will receive onExecuted
|
||||
propagateLivePreview(node, value, {
|
||||
outputName: inputSpec.name,
|
||||
setOutputData: true,
|
||||
updateWidget: true,
|
||||
callOnExecuted: true
|
||||
})
|
||||
}
|
||||
|
||||
const widget = multiline
|
||||
? addMultilineWidget(node, inputSpec.name, {
|
||||
defaultVal,
|
||||
@@ -130,6 +150,23 @@ export const useStringWidget = () => {
|
||||
widget.dynamicPrompts = inputSpec.dynamicPrompts
|
||||
}
|
||||
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = function (value: WidgetValue) {
|
||||
if (originalCallback) {
|
||||
;(originalCallback as any).call(this, value)
|
||||
}
|
||||
|
||||
const input = node.inputs?.find(
|
||||
(input) => input.widget?.name === inputSpec.name
|
||||
)
|
||||
|
||||
if (input?.link) {
|
||||
return
|
||||
}
|
||||
|
||||
propagateCallback(value)
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user