live preview - String length and concatenate node

This commit is contained in:
Terry Jia
2025-11-19 22:39:09 -05:00
parent bdf6d4dea2
commit c781421cad
5 changed files with 468 additions and 0 deletions

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

View File

@@ -14,6 +14,7 @@ import './matchType'
import './nodeTemplates'
import './noteNode'
import './previewAny'
import './stringOperations'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'

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

View File

@@ -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)

View File

@@ -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
}