mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 16:58:33 +00:00
## 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>
383 lines
11 KiB
TypeScript
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')
|
|
)
|
|
}
|