Files
ComfyUI_frontend/src/utils/litegraphUtil.ts
jaeone94 d9466947b2 feat: detect and resolve missing media inputs in error tab (#10309)
## Summary

Add detection and resolution UI for missing image/video/audio inputs
(LoadImage, LoadVideo, LoadAudio nodes) in the Errors tab, mirroring the
existing missing model pipeline.

## Changes

- **What**: New `src/platform/missingMedia/` module — scan pipeline
detects missing media files on workflow load (sync for OSS, async for
cloud), surfaces them in the error tab with upload dropzone, thumbnail
library select, and 2-step confirm flow
- **Detection**: `scanAllMediaCandidates()` checks combo widget values
against options; cloud path defers to `verifyCloudMediaCandidates()` via
`assetsStore.updateInputs()`
- **UI**: `MissingMediaCard` groups by media type; `MissingMediaRow`
shows node name (single) or filename+count (multiple), upload dropzone
with drag & drop, `MissingMediaLibrarySelect` with image/video
thumbnails
- **Resolution**: Upload via `/upload/image` API or select from library
→ status card → checkmark confirm → widget value applied, item removed
from error list
- **Integration**: `executionErrorStore` aggregates into
`hasAnyError`/`totalErrorCount`; `useNodeErrorFlagSync` flags nodes on
canvas; `useErrorGroups` renders in error tab
- **Shared**: Extract `ACCEPTED_IMAGE_TYPES`/`ACCEPTED_VIDEO_TYPES` to
`src/utils/mediaUploadUtil.ts`; extract `resolveComboValues` to
`src/utils/litegraphUtil.ts` (shared across missingMedia + missingModel
scan)
- **Reverse clearing**: Widget value changes on nodes auto-remove
corresponding missing media errors (via `clearWidgetRelatedErrors`)

## Testing

### Unit tests (22 tests)
- `missingMediaScan.test.ts` (12): groupCandidatesByName,
groupCandidatesByMediaType (ordering, multi-name),
verifyCloudMediaCandidates (missing/present, abort before/after
updateInputs, already resolved true/false, no-pending skip, updateInputs
spy)
- `missingMediaStore.test.ts` (10): setMissingMedia, clearMissingMedia
(full lifecycle with interaction state), missingMediaNodeIds,
hasMissingMediaOnNode, removeMissingMediaByWidget
(match/no-match/last-entry), createVerificationAbortController

### E2E tests (10 scenarios in `missingMedia.spec.ts`)
- Detection: error overlay shown, Missing Inputs group in errors tab,
correct row count, dropzone + library select visibility, no false
positive for valid media
- Upload flow: file picker → uploading status card → confirm → row
removed
- Library select: dropdown → selected status card → confirm → row
removed
- Cancel: pending selection → returns to upload/library UI
- All resolved: Missing Inputs group disappears
- Locate node: canvas pans to missing media node

## Review Focus

- Cloud verification path: `verifyCloudMediaCandidates` compares widget
value against `asset_hash` — implicit contract
- 2-step confirm mirrors missing model pattern (`pendingSelection` →
confirm/cancel)
- Event propagation guard on dropzone (`@drop.prevent.stop`) to prevent
canvas LoadImage node creation
- `clearAllErrors()` intentionally does NOT clear missing media (same as
missing models — preserves pending repairs)
- `runMissingMediaPipeline` is now `async` and `await`-ed, matching
model pipeline

## Test plan

- [x] OSS: load workflow with LoadImage referencing non-existent file →
error tab shows it
- [x] Upload file via dropzone → status card shows "Uploaded" → confirm
→ widget updated, error removed
- [x] Select from library with thumbnail preview → confirm → widget
updated, error removed
- [x] Cancel pending selection → returns to upload/library UI
- [x] Load workflow with valid images → no false positives
- [x] Click locate-node → canvas navigates to the node
- [x] Multiple nodes referencing different missing files → correct row
count
- [x] Widget value change on node → missing media error auto-removed

## Screenshots


https://github.com/user-attachments/assets/631c0cb0-9706-4db2-8615-f24a4c3fe27d
2026-04-01 17:59:02 +09:00

364 lines
11 KiB
TypeScript

import _ from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
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 { app } from '@/scripts/app'
import { t } from '@/i18n'
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) 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] | [] {
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] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
if (widget) return [node, widget]
}
return []
}
export function isLoad3dNode(node: LGraphNode) {
return (
node &&
node.type &&
(node.type === 'Load3D' || node.type === 'Load3DAnimation')
)
}