Files
ComfyUI_frontend/src/utils/litegraphUtil.ts
Alexander Brown e994e4df58 refactor: store-backed WidgetId subgraph host widgets; delete widgetValueIO layer (#12617)
## Summary

Make `WidgetId` (`graphId:nodeId:name`) the single canonical widget
identity and represent subgraph promoted host widgets as ordinary
store-backed widgets addressed by it. This deletes two whole indirection
layers — the `world/*` widget-entity-IO layer and the
`PromotedWidgetView` runtime — leaving one model: a widget's data lives
in `widgetValueStore` keyed by `WidgetId`, and a `SubgraphNode` input
references it via `input.widgetId`.

**Net +304 lines across 107 files** (5,044 added / 4,740 deleted):
production code is net **−798** (1,521 added / 2,319 deleted) while
tests are net **+1,102** (3,523 added / 2,421 deleted). 10 files deleted
outright, 14 added.

## What got deleted

The old design wrapped every promoted subgraph widget in a synthetic
`IBaseWidget` "view" object with live getters that followed the source
widget, plus a manager to keep view identities stable, plus an IO
indirection layer over the store. All of it is gone:

- `promotedWidgetView.ts` — the `PromotedWidgetView` class (draw /
pointer / DOM-sync / projection / deepest-source resolution getters)
- `PromotedWidgetViewManager.ts` — view reconciliation/caching
- `world/widgetValueIO.ts` — the IO wrapper over `widgetValueStore`
- `world/entityIds.ts` + `world/brand.ts` — the `WidgetEntityId`
branded-id layer and the `entityId` field
- `widgetNodeTypeGuard.ts` — only used by the deleted view
- the per-`SubgraphNode` view machinery (`_promotedViewManager`,
`_cacheVersion`, view-key generation, DOM position-override cleanup) and
every now-dead `isPromotedWidgetView` branch across the panel, menu,
store, and util consumers
- `domWidgetStore` position-override APIs (`setPositionOverride` /
`clearPositionOverride`), only used to render a promoted DOM widget on a
different host node

## Why it's simpler

- One source of truth. A promoted host widget is `WidgetState` in the
store, seeded from the source at promotion (`registerWidget` with a
deep-cloned snapshot) and independent thereafter. No synthetic widget
objects, no runtime source-following, no view cache to invalidate.
- Resolution is data-driven. `resolveConcretePromotedWidget` walks
`SubgraphNode` inputs (`input.widgetId` + `resolveSubgraphInputTarget`)
instead of chasing view objects through `node.widgets`. This also
**fixes two-layer nested promotion** — the previously-skipped parity
test now passes and resolves through to the deepest concrete widget.
- The right-panel Parameters tab renders a subgraph node's promoted
widgets through the **same** store-backed path as ordinary node widgets:
display reads `WidgetState` via `widget.widgetId`, and value writes go
through `widgetValueStore.setValue(widgetId)`.

## Changes

- **What**:
- `WidgetId` branded type + `widgetId()` / `parseWidgetId()` /
`isWidgetId()` and a `WidgetState` type; `widgetValueStore` is
`WidgetId`-native (`registerWidget` / `getWidget` / `setValue` /
`deleteWidget`).
- Promotion creates host `WidgetState` then an input projection
(`input.widgetId`); demotion clears it; serialization and legacy
`proxyWidgets` migration round-trip through `input.widgetId`.
- `promotedInputWidget.ts` projects a store-backed ordinary widget from
an input slot; `SubgraphNode.widgets` is now a projected getter over
inputs (kept Litegraph-shaped so the canvas renderer and extensions
still read `node.widgets`). `invalidatePromotedViews()` is retained as a
no-op for extension compatibility.
- `promotedWidgetControl.ts` applies `control_after_generate` (e.g. seed
increment) on the host node, since the interior control widget is
link-fed and its value is dead; `syncPromotedComboHostOptions` mirrors
interior combo options onto host state.
- `multilineTextarea.ts` extracts the reusable multiline DOM-widget
behavior out of `useStringWidget` and adds promoted multiline
materialization via a `createPromotedHostWidget` app-layer hook (keeping
Litegraph core free of Vue/Pinia/DOM).
- Late-bound `LiteGraph` singleton holder (`litegraphInstance.ts`) to
break a widget-init import cycle.
- **Breaking**:
  - `IBaseWidget.entityId` removed — use `widgetId`.
- `SubgraphNode.widgets` no longer exposes the old `PromotedWidgetView`
objects; promoted state lives in `widgetValueStore` keyed by
`input.widgetId` and `widgets` is a projection of inputs. The
`widget-promoted` event now carries the concrete interior widget.
Extension code reading `entityId` or relying on `PromotedWidgetView` is
affected.

## Review Focus

- Promotion / demotion / serialization round-tripping through
`input.widgetId` + `widgetValueStore`, incl. the legacy `proxyWidgets`
migration.
- Snapshot-at-promotion semantics (host widget does not follow the
source after creation), and the combo-options exception via
`syncPromotedComboHostOptions`.
- Two-layer nested resolution in `resolveConcretePromotedWidget` +
`SubgraphNode` nested-source resolution.
- The unified Parameters tab (`TabSubgraphInputs` → `SectionWidgets`):
value edit / rename / favorite / hide / reorder for promoted inputs are
wired through the store but warrant a visual/e2e pass.
- Litegraph-compat seams worth a careful read: projected
`SubgraphNode.widgets`, the canvas-edit `callback` bridge back to the
store, host-level `control_after_generate`, and the late-bound
`LiteGraph` holder / `domWidget.ts` import ordering.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-16 23:19:12 +00:00

383 lines
11 KiB
TypeScript

import _ from 'es-toolkit/compat'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
LGraphCanvas,
LGraphGroup,
LGraphNode,
LiteGraph,
Reroute,
isColorable
} from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraph,
ISerialisableNodeInput,
ISerialisedGraph
} from '@/lib/litegraph/src/types/serialisation'
import type {
IBaseWidget,
IComboWidget,
WidgetCallbackOptions
} from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { app } from '@/scripts/app'
import { t } from '@/i18n'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
videoContainer: HTMLElement | undefined
imgs: HTMLVideoElement[] | undefined
}
/**
* Extract & Promisify Litegraph.createNode to allow for positioning
* @param canvas
* @param name
*/
export async function createNode(
canvas: LGraphCanvas,
name: string
): Promise<LGraphNode | null> {
if (!name) {
return null
}
const {
graph,
graph_mouse: [posX, posY]
} = canvas
const newNode = LiteGraph.createNode(name)
await new Promise((r) => setTimeout(r, 0))
if (newNode && graph) {
newNode.pos = [posX, posY]
const addedNode = graph.add(newNode) ?? null
if (addedNode) {
useNodeZIndex().bringNodeToFront(addedNode.id)
graph.change()
}
return addedNode
} else {
useToastStore().addAlert(t('assetBrowser.failedToCreateNode'))
return null
}
}
export function isImageNode(node: LGraphNode | undefined): node is ImageNode {
if (!node) return false
return (
node.previewMediaType === 'image' ||
(node.previewMediaType !== 'video' && !!node.imgs?.length)
)
}
export function isVideoNode(node: LGraphNode | undefined): node is VideoNode {
if (!node) return false
return node.previewMediaType === 'video' || !!node.videoContainer
}
/**
* Check if output data indicates animated content (animated webp/png or video).
*/
export function isAnimatedOutput(
output: ExecutedWsMessage['output'] | undefined
): boolean {
return !!output?.animated?.find(Boolean)
}
/**
* Check if output data indicates video content (animated but not webp/png).
*/
export function isVideoOutput(
output: ExecutedWsMessage['output'] | undefined
): boolean {
if (!isAnimatedOutput(output)) return false
const isAnimatedWebp = output?.images?.some((img) =>
img.filename?.endsWith('.webp')
)
const isAnimatedPng = output?.images?.some((img) =>
img.filename?.endsWith('.png')
)
return !isAnimatedWebp && !isAnimatedPng
}
export function isAudioNode(node: LGraphNode | undefined): boolean {
return !!node && node.previewMediaType === 'audio'
}
export function resolveComboValues(widget: IComboWidget): string[] {
const values = widget.options?.values
if (!values) return []
if (typeof values === 'function') return values(widget)
if (Array.isArray(values)) return values
return Object.keys(values)
}
export function addToComboValues(widget: IComboWidget, value: string) {
if (!widget.options) widget.options = { values: [] }
if (!widget.options.values) widget.options.values = []
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
if (!widget.options.values.includes(value)) {
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
widget.options.values.push(value)
}
}
export const isLGraphNode = (item: unknown): item is LGraphNode => {
return item instanceof LGraphNode
}
export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
return item instanceof LGraphGroup
}
export const isReroute = (item: unknown): item is Reroute => {
return item instanceof Reroute
}
/**
* Get the color option of all canvas items if they are all the same.
* @param items - The items to get the color option of.
* @returns The color option of the item.
*/
export const getItemsColorOption = (items: unknown[]): ColorOption | null => {
const validItems = _.filter(items, isColorable)
if (_.isEmpty(validItems)) return null
const colorOptions = _.map(validItems, (item) => item.getColorOption())
return _.every(colorOptions, (option) =>
_.isEqual(option, _.head(colorOptions))
)
? _.head(colorOptions)!
: null
}
export function executeWidgetsCallback(
nodes: LGraphNode[],
callbackName: 'onRemove' | 'beforeQueued' | 'afterQueued',
options?: WidgetCallbackOptions
) {
for (const node of nodes) {
for (const widget of node.widgets ?? []) {
widget[callbackName]?.(options)
}
}
}
/**
* Since frontend version 1.16, forceInput input is no longer treated
* as widget. So we need to remove the dummy widget value serialized
* from workflows prior to v1.16.
* Ref: https://github.com/Comfy-Org/ComfyUI_frontend/pull/3326
*
* @param nodeDef the node definition
* @param widgets the widgets on the node instance (from node definition)
* @param widgetsValues the widgets values to populate the node during configuration
* @returns the widgets values without the dummy widget values
*/
export function migrateWidgetsValues<TWidgetValue>(
inputDefs: Record<string, InputSpec>,
widgets: IBaseWidget[],
widgetsValues: TWidgetValue[]
): TWidgetValue[] {
const widgetNames = new Set(widgets.map((w) => w.name))
const originalWidgetsInputs = Object.values(inputDefs).filter(
(input) => widgetNames.has(input.name) || input.forceInput
)
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input) =>
input.control_after_generate
? [!!input.forceInput, false]
: [!!input.forceInput]
)
if (widgetIndexHasForceInput.length !== widgetsValues?.length)
return widgetsValues
return widgetsValues.filter((_, index) => !widgetIndexHasForceInput[index])
}
/**
* Fix link input slots after loading a graph. Because the node inputs follows
* the node definition after 1.16, the node inputs array from previous versions,
* might get added items in the middle, which can cause shift to link's slot index.
* For example, the node inputs definition is:
* "required": {
* "input1": ["INT", { forceInput: true }],
* "input2": ["MODEL", { forceInput: false }],
* "input3": ["MODEL", { forceInput: false }]
* }
*
* previously node inputs array was:
* [{name: 'input2'}, {name: 'input3'}, {name: 'input1'}]
* because input1 is created as widget first, then convert to input socket after
* input 2 and 3.
*
* Now, the node inputs array just follows the definition order:
* [{name: 'input1'}, {name: 'input2'}, {name: 'input3'}]
*
* We need to update the slot index of corresponding links to match the new
* node inputs array order.
*
* Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
*
* @param graph - The graph to fix links for.
*/
export function fixLinkInputSlots(graph: LGraph) {
// Note: We can't use forEachNode here because we need access to the graph's
// links map at each level. Links are stored in their respective graph/subgraph.
for (const node of graph.nodes) {
// Fix links for the current node
for (const [inputIndex, input] of node.inputs.entries()) {
const linkId = input.link
if (!linkId) continue
const link = graph.links.get(linkId)
if (!link) continue
link.target_slot = inputIndex
}
// Recursively fix links in subgraphs
if (node.isSubgraphNode?.() && node.subgraph) {
fixLinkInputSlots(node.subgraph)
}
}
}
/**
* Compress widget input slots by removing all unconnected widget input slots.
* This should match the serialization format of legacy widget conversion.
*
* @param graph - The graph to compress widget input slots for.
* @throws If an infinite loop is detected.
*/
export function compressWidgetInputSlots(graph: ISerialisedGraph) {
for (const node of graph.nodes) {
node.inputs = node.inputs?.filter(matchesLegacyApi)
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
const link = graph.links.find((link) => link[0] === input.link)
if (link) {
link[4] = inputIndex
}
}
}
}
compressSubgraphWidgetInputSlots(graph.definitions?.subgraphs)
}
function matchesLegacyApi(input: ISerialisableNodeInput) {
return !(input.widget && input.link === null && !input.label)
}
/**
* Duplication to handle the legacy link arrays in the root workflow.
* @see compressWidgetInputSlots
* @param subgraph The subgraph to compress widget input slots for.
*/
function compressSubgraphWidgetInputSlots(
subgraphs: ExportedSubgraph[] | undefined,
visited = new WeakSet<ExportedSubgraph>()
) {
if (!subgraphs) return
for (const subgraph of subgraphs) {
if (visited.has(subgraph)) throw new Error('Infinite loop detected')
visited.add(subgraph)
if (subgraph.nodes) {
for (const node of subgraph.nodes) {
node.inputs = node.inputs?.filter(matchesLegacyApi)
if (!subgraph.links) continue
for (const [inputIndex, input] of node.inputs?.entries() ?? []) {
if (input.link) {
const link = subgraph.links.find((link) => link.id === input.link)
if (link) link.target_slot = inputIndex
}
}
}
}
compressSubgraphWidgetInputSlots(subgraph.definitions?.subgraphs, visited)
}
}
export function getLinkTypeColor(typeName: string): string {
return LGraphCanvas.link_type_colors[typeName] ?? LiteGraph.LINK_COLOR
}
export function resolveNode(
nodeId: NodeId,
graph: LGraph | null | undefined = app.rootGraph
): LGraphNode | undefined {
if (!graph) return undefined
const found = graph.getNodeById(nodeId)
if (found) return found
for (const sg of graph.subgraphs.values()) {
const node = sg.getNodeById(nodeId)
if (node) return node
}
return undefined
}
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
if (widgetName && typeof nodeId === 'string') {
const locator = parseNodeLocatorId(nodeId)
if (locator?.subgraphUuid) {
const host = graph.getNodeById(locator.localNodeId)
if (host?.isSubgraphNode()) {
const widget = host.widgets?.find((w) => w.name === widgetName)
return widget ? [host, widget] : []
}
}
}
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {
const widget = node.widgets?.find((w) => w.name === widgetName)
return widget ? [node, widget] : []
}
return []
}
export function getWidgetIdForNode(
node: LGraphNode,
widget: Pick<IBaseWidget, 'name' | 'widgetId'>,
duplicateIndex = 0
): WidgetId | undefined {
if (widget.widgetId) return widget.widgetId
const graphId = node.graph?.rootGraph.id
if (!graphId || node.id === -1) return undefined
const name =
duplicateIndex > 0 ? `${widget.name}#${duplicateIndex}` : widget.name
return widgetId(graphId, node.id, name)
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&
node.type &&
(node.type === 'Load3D' || node.type === 'Load3DAnimation')
)
}