From c781421cad7fceb659e311c3d5032bcd3cd22bca Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Wed, 19 Nov 2025 22:39:09 -0500 Subject: [PATCH] live preview - String length and concatenate node --- src/composables/useLivePreview.ts | 344 ++++++++++++++++++ src/extensions/core/index.ts | 1 + src/extensions/core/stringOperations.ts | 58 +++ .../vueNodes/components/NodeWidgets.vue | 28 ++ .../widgets/composables/useStringWidget.ts | 37 ++ 5 files changed, 468 insertions(+) create mode 100644 src/composables/useLivePreview.ts create mode 100644 src/extensions/core/stringOperations.ts diff --git a/src/composables/useLivePreview.ts b/src/composables/useLivePreview.ts new file mode 100644 index 000000000..de8766409 --- /dev/null +++ b/src/composables/useLivePreview.ts @@ -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 +} + +/** + * 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>() +const nodeCalculators = new WeakMap() + +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 + } +} diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index 4171dce89..acca9cba5 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -14,6 +14,7 @@ import './matchType' import './nodeTemplates' import './noteNode' import './previewAny' +import './stringOperations' import './rerouteNode' import './saveImageExtraOutput' import './saveMesh' diff --git a/src/extensions/core/stringOperations.ts b/src/extensions/core/stringOperations.ts new file mode 100644 index 000000000..809c47bf9 --- /dev/null +++ b/src/extensions/core/stringOperations.ts @@ -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 + } + }) + } + } + } +}) \ No newline at end of file diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 10a06ff15..bd1e6b48f 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -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() +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) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts index 1ace7e9e2..c735540e8 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts @@ -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 }