mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
## 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
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
import type { Ref } from 'vue'
|
|
import { computed, watch } from 'vue'
|
|
|
|
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { app } from '@/scripts/app'
|
|
import type { NodeError } from '@/schemas/apiSchema'
|
|
import { getParentExecutionIds } from '@/types/nodeIdentification'
|
|
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
|
|
|
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
|
if (node.has_errors === hasErrors) return
|
|
const oldValue = node.has_errors
|
|
node.has_errors = hasErrors
|
|
node.graph?.trigger('node:property:changed', {
|
|
type: 'node:property:changed',
|
|
nodeId: node.id,
|
|
property: 'has_errors',
|
|
oldValue,
|
|
newValue: hasErrors
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Single-pass reconciliation of node error flags.
|
|
* Collects the set of nodes that should have errors, then walks all nodes
|
|
* once, setting each flag exactly once. This avoids the redundant
|
|
* true→false→true transition (and duplicate events) that a clear-then-apply
|
|
* approach would cause.
|
|
*/
|
|
function reconcileNodeErrorFlags(
|
|
rootGraph: LGraph,
|
|
nodeErrors: Record<string, NodeError> | null,
|
|
missingModelExecIds: Set<string>,
|
|
missingMediaExecIds: Set<string> = new Set()
|
|
): void {
|
|
// Collect nodes and slot info that should be flagged
|
|
// Includes both error-owning nodes and their ancestor containers
|
|
const flaggedNodes = new Set<LGraphNode>()
|
|
const errorSlots = new Map<LGraphNode, Set<string>>()
|
|
|
|
if (nodeErrors) {
|
|
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
|
|
const node = getNodeByExecutionId(rootGraph, executionId)
|
|
if (!node) continue
|
|
|
|
flaggedNodes.add(node)
|
|
const slotNames = new Set<string>()
|
|
for (const error of nodeError.errors) {
|
|
const name = error.extra_info?.input_name
|
|
if (name) slotNames.add(name)
|
|
}
|
|
if (slotNames.size > 0) errorSlots.set(node, slotNames)
|
|
|
|
for (const parentId of getParentExecutionIds(executionId)) {
|
|
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
|
if (parentNode) flaggedNodes.add(parentNode)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const execId of missingModelExecIds) {
|
|
const node = getNodeByExecutionId(rootGraph, execId)
|
|
if (node) flaggedNodes.add(node)
|
|
}
|
|
|
|
for (const execId of missingMediaExecIds) {
|
|
const node = getNodeByExecutionId(rootGraph, execId)
|
|
if (node) flaggedNodes.add(node)
|
|
}
|
|
|
|
forEachNode(rootGraph, (node) => {
|
|
setNodeHasErrors(node, flaggedNodes.has(node))
|
|
|
|
if (node.inputs) {
|
|
const nodeSlotNames = errorSlots.get(node)
|
|
for (const slot of node.inputs) {
|
|
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
export function useNodeErrorFlagSync(
|
|
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
|
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
|
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
|
): () => void {
|
|
const settingStore = useSettingStore()
|
|
const showErrorsTab = computed(() =>
|
|
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
|
|
)
|
|
|
|
const stop = watch(
|
|
[
|
|
lastNodeErrors,
|
|
() => missingModelStore.missingModelNodeIds,
|
|
() => missingMediaStore.missingMediaNodeIds,
|
|
showErrorsTab
|
|
],
|
|
() => {
|
|
if (!app.isGraphReady) return
|
|
// Legacy (LGraphNode) only: suppress missing-model/media error flags
|
|
// when the Errors tab is hidden, since legacy nodes lack the per-widget
|
|
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
|
// Vue nodes compute hasAnyError independently and are unaffected.
|
|
reconcileNodeErrorFlags(
|
|
app.rootGraph,
|
|
lastNodeErrors.value,
|
|
showErrorsTab.value
|
|
? missingModelStore.missingModelAncestorExecutionIds
|
|
: new Set(),
|
|
showErrorsTab.value
|
|
? missingMediaStore.missingMediaAncestorExecutionIds
|
|
: new Set()
|
|
)
|
|
},
|
|
{ flush: 'post' }
|
|
)
|
|
return stop
|
|
}
|