mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-14 01:36:14 +00:00
## Summary Muted and bypassed nodes are excluded from execution but were still triggering missing model/media/node warnings. This PR makes the error system mode-aware: muted/bypassed nodes no longer produce missing asset errors, and all error lifecycle events (mode toggle, deletion, paste, undo, tab switch) are handled consistently. - Fixes Comfy-Org/ComfyUI#13256 ## Behavioral notes - **Tab switch overlay suppression (intentional)**: Switching back to a workflow with missing assets no longer re-shows the error overlay. This reverses the behavior introduced in #10190. The error state is still restored silently in the errors tab — users can access it via the properties panel without being interrupted by the overlay on every tab switch. ## Changes ### 1. Scan filtering - `scanAllModelCandidates`, `scanAllMediaCandidates`, `scanMissingNodes`: skip nodes with `mode === NEVER || BYPASS` - `collectMissingNodes` (serialized data): skip error reporting for muted/bypassed nodes while still calling `sanitizeNodeName` for safe `configure()` - `collectEmbeddedModelsWithSource`: skip muted/bypassed nodes; workflow-level `graphData.models` only create candidates when active nodes exist - `enrichWithEmbeddedMetadata`: filter unmatched workflow-level models when all referencing nodes are inactive ### 2. Realtime mode change handling - `useErrorClearingHooks.ts` chains `graph.onTrigger` to detect `node:property:changed` (mode) - Deactivation (active → muted/bypassed): remove missing model/media/node errors for the node - Activation (muted/bypassed → active): scan the node and add confirmed errors, show overlay - Subgraph container deactivation: remove all interior node errors (execution ID prefix match) - Subgraph container activation: scan all active interior nodes recursively - Subgraph interior mode change: resolve node via `localGraph.getNodeById()` then compute execution ID from root graph ### 3. Node deletion - `graph.onNodeRemoved`: remove missing model/media/node errors for the deleted node - Handle `node.graph === null` at callback time by using `String(node.id)` for root-level nodes ### 4. Node paste/duplicate - `graph.onNodeAdded`: scan via `queueMicrotask` (deferred until after `node.configure()` restores widget values) - Guard: skip during `ChangeTracker.isLoadingGraph` (undo/redo/tab switch handled by pipeline) - Guard: skip muted/bypassed nodes ### 5. Workflow tab switch optimization - `skipAssetScans` option in `loadGraphData`: skip full pipeline on tab switch - Cache missing model/media/node state per workflow via `PendingWarnings` - `beforeLoadNewGraph`: save current store state to outgoing workflow's `pendingWarnings` - `showPendingWarnings`: restore cached errors silently (no overlay), always sync missing nodes store (even when null) - Preserve UI state (`fileSizes`, `urlInputs`) on tab switch by using `setMissingModels([])` instead of `clearMissingModels()` - `MissingModelRow.vue`: fetch file size on mount via `fetchModelMetadata` memory cache ### 6. Undo/redo overlay suppression - `silentAssetErrors` option propagated through pipeline → `surfaceMissingModels`/`surfaceMissingMedia` `{ silent }` option - `showPendingWarnings` `{ silent }` option for missing nodes overlay - `changeTracker.ts`: pass `silentAssetErrors: true` on undo/redo ### 7. Error tab node filtering - Selected node filters missing model/media card contents (not just group visibility) - `isAssetErrorInSelection`: resolve execution ID → graph node for selection matching - Missing nodes intentionally unfiltered (pack-level scope) - `hasMissingMediaSelected` added to `RightSidePanel.vue` error tab visibility - Download All button: show only when 2+ downloadable models exist ### 8. New store functions - `missingModelStore`: `addMissingModels`, `removeMissingModelsByNodeId` - `missingMediaStore`: `addMissingMedia`, `removeMissingMediaByNodeId` - `missingNodesErrorStore`: `removeMissingNodesByNodeId` - `missingModelScan`: `scanNodeModelCandidates` (extracted single-node scan) - `missingMediaScan`: `scanNodeMediaCandidates` (extracted single-node scan) ### 9. Test infrastructure improvements - `data-testid` on `RightSidePanel.vue` tabs (`panel-tab-{value}`) - Error-related TestIds moved from `dialogs` to `errorsTab` namespace in `selectors.ts` - Removed unused `TestIdValue` type - Extracted `cleanupFakeModel` to shared `ErrorsTabHelper.ts` - Renamed `openErrorsTabViaSeeErrors` → `loadWorkflowAndOpenErrorsTab` - Added `aria-label` to pencil edit button and subgraph toggle button ## Test plan ### Unit tests (41 new) - Store functions: `addMissing*`, `removeMissing*ByNodeId` - `executionErrorStore`: `surfaceMissing*` silent option - Scan functions: muted/bypassed filtering, `scanNodeModelCandidates`, `scanNodeMediaCandidates` - `workflowService`: `showPendingWarnings` silent, `beforeLoadNewGraph` caching ### E2E tests (17 new in `errorsTabModeAware.spec.ts`) **Missing nodes** - [x] Deleting a missing node removes its error from the errors tab - [x] Undo after bypass restores error without showing overlay **Missing models** - [x] Loading a workflow with all nodes bypassed shows no errors - [x] Bypassing a node hides its error, un-bypassing restores it - [x] Deleting a node with missing model removes its error - [x] Undo after bypass restores error without showing overlay - [x] Pasting a node with missing model increases referencing node count - [x] Pasting a bypassed node does not add a new error - [x] Selecting a node filters errors tab to only that node **Missing media** - [x] Loading a workflow with all nodes bypassed shows no errors - [x] Bypassing a node hides its error, un-bypassing restores it - [x] Pasting a bypassed node does not add a new error - [x] Selecting a node filters errors tab to only that node **Subgraph** - [x] Bypassing a subgraph hides interior errors, un-bypassing restores them - [x] Bypassing a node inside a subgraph hides its error, un-bypassing restores it **Workflow switching** - [x] Does not resurface error overlay when switching back to workflow with missing nodes - [x] Restores missing nodes in errors tab when switching back to workflow # Screenshots https://github.com/user-attachments/assets/e0a5bcb8-69ba-4120-ab7f-5c83e4cfc3c5 ## Follow-up work - Extract error-detection computed properties from `RightSidePanel.vue` into a composable (e.g. `useErrorsTabVisibility`) --------- Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: GitHub Action <action@github.com>
180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
import { groupBy } from 'es-toolkit'
|
|
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
|
import type {
|
|
MissingMediaCandidate,
|
|
MissingMediaViewModel,
|
|
MissingMediaGroup,
|
|
MediaType
|
|
} from './types'
|
|
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
|
import type {
|
|
IBaseWidget,
|
|
IComboWidget
|
|
} from '@/lib/litegraph/src/types/widgets'
|
|
import {
|
|
collectAllNodes,
|
|
getExecutionIdByNode
|
|
} from '@/utils/graphTraversalUtil'
|
|
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { resolveComboValues } from '@/utils/litegraphUtil'
|
|
|
|
/** Map of node types to their media widget name and media type. */
|
|
const MEDIA_NODE_WIDGETS: Record<
|
|
string,
|
|
{ widgetName: string; mediaType: MediaType }
|
|
> = {
|
|
LoadImage: { widgetName: 'image', mediaType: 'image' },
|
|
LoadVideo: { widgetName: 'file', mediaType: 'video' },
|
|
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
|
|
}
|
|
|
|
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
|
return widget.type === 'combo'
|
|
}
|
|
|
|
/**
|
|
* Scan combo widgets on media nodes for file values that may be missing.
|
|
*
|
|
* OSS: `isMissing` resolved immediately via widget options.
|
|
* Cloud: `isMissing` left `undefined` for async verification.
|
|
*/
|
|
export function scanAllMediaCandidates(
|
|
rootGraph: LGraph,
|
|
isCloud: boolean
|
|
): MissingMediaCandidate[] {
|
|
if (!rootGraph) return []
|
|
|
|
const allNodes = collectAllNodes(rootGraph)
|
|
const candidates: MissingMediaCandidate[] = []
|
|
|
|
for (const node of allNodes) {
|
|
if (!node.widgets?.length) continue
|
|
if (node.isSubgraphNode?.()) continue
|
|
if (
|
|
node.mode === LGraphEventMode.NEVER ||
|
|
node.mode === LGraphEventMode.BYPASS
|
|
)
|
|
continue
|
|
|
|
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
/** Scan a single node for missing media candidates (OSS immediate resolution). */
|
|
export function scanNodeMediaCandidates(
|
|
rootGraph: LGraph,
|
|
node: LGraphNode,
|
|
isCloud: boolean
|
|
): MissingMediaCandidate[] {
|
|
if (!node.widgets?.length) return []
|
|
|
|
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
|
if (!mediaInfo) return []
|
|
|
|
const executionId = getExecutionIdByNode(rootGraph, node)
|
|
if (!executionId) return []
|
|
|
|
const candidates: MissingMediaCandidate[] = []
|
|
for (const widget of node.widgets) {
|
|
if (!isComboWidget(widget)) continue
|
|
if (widget.name !== mediaInfo.widgetName) continue
|
|
|
|
const value = widget.value
|
|
if (typeof value !== 'string' || !value.trim()) continue
|
|
|
|
let isMissing: boolean | undefined
|
|
if (isCloud) {
|
|
isMissing = undefined
|
|
} else {
|
|
const options = resolveComboValues(widget)
|
|
isMissing = !options.includes(value)
|
|
}
|
|
|
|
candidates.push({
|
|
nodeId: executionId as NodeId,
|
|
nodeType: node.type,
|
|
widgetName: widget.name,
|
|
mediaType: mediaInfo.mediaType,
|
|
name: value,
|
|
isMissing
|
|
})
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
interface InputVerifier {
|
|
updateInputs: () => Promise<unknown>
|
|
inputAssets: Array<{ asset_hash?: string | null; name: string }>
|
|
}
|
|
|
|
/**
|
|
* Verify cloud media candidates against the input assets fetched from the
|
|
* assets store. Mutates candidates' `isMissing` in place.
|
|
*/
|
|
export async function verifyCloudMediaCandidates(
|
|
candidates: MissingMediaCandidate[],
|
|
signal?: AbortSignal,
|
|
assetsStore?: InputVerifier
|
|
): Promise<void> {
|
|
if (signal?.aborted) return
|
|
|
|
const pending = candidates.filter((c) => c.isMissing === undefined)
|
|
if (pending.length === 0) return
|
|
|
|
const store =
|
|
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
|
|
|
await store.updateInputs()
|
|
|
|
if (signal?.aborted) return
|
|
|
|
const assetHashes = new Set(
|
|
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
|
)
|
|
|
|
for (const c of pending) {
|
|
c.isMissing = !assetHashes.has(c.name)
|
|
}
|
|
}
|
|
|
|
/** Group confirmed-missing candidates by file name into view models. */
|
|
export function groupCandidatesByName(
|
|
candidates: MissingMediaCandidate[]
|
|
): MissingMediaViewModel[] {
|
|
const map = new Map<string, MissingMediaViewModel>()
|
|
for (const c of candidates) {
|
|
const existing = map.get(c.name)
|
|
if (existing) {
|
|
existing.referencingNodes.push({
|
|
nodeId: c.nodeId,
|
|
widgetName: c.widgetName
|
|
})
|
|
} else {
|
|
map.set(c.name, {
|
|
name: c.name,
|
|
mediaType: c.mediaType,
|
|
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
|
})
|
|
}
|
|
}
|
|
return Array.from(map.values())
|
|
}
|
|
|
|
/** Group confirmed-missing candidates by media type. */
|
|
export function groupCandidatesByMediaType(
|
|
candidates: MissingMediaCandidate[]
|
|
): MissingMediaGroup[] {
|
|
const grouped = groupBy(candidates, (c) => c.mediaType)
|
|
const order: MediaType[] = ['image', 'video', 'audio']
|
|
return order
|
|
.filter((t) => t in grouped)
|
|
.map((mediaType) => ({
|
|
mediaType,
|
|
items: groupCandidatesByName(grouped[mediaType])
|
|
}))
|
|
}
|