mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +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
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import { app } from '@/scripts/app'
|
|
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
|
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
|
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
|
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
|
|
/**
|
|
* Missing media error state.
|
|
* Separated from executionErrorStore to keep domain boundaries clean.
|
|
* The executionErrorStore composes from this store for aggregate error flags.
|
|
*/
|
|
export const useMissingMediaStore = defineStore('missingMedia', () => {
|
|
const canvasStore = useCanvasStore()
|
|
|
|
const missingMediaCandidates = ref<MissingMediaCandidate[] | null>(null)
|
|
|
|
const hasMissingMedia = computed(() => !!missingMediaCandidates.value?.length)
|
|
|
|
const missingMediaCount = computed(
|
|
() => missingMediaCandidates.value?.length ?? 0
|
|
)
|
|
|
|
const missingMediaNodeIds = computed(
|
|
() =>
|
|
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
|
|
)
|
|
|
|
/**
|
|
* Set of all execution ID prefixes derived from missing media node IDs,
|
|
* including the missing media nodes themselves.
|
|
*/
|
|
const missingMediaAncestorExecutionIds = computed<Set<NodeExecutionId>>(
|
|
() => {
|
|
const ids = new Set<NodeExecutionId>()
|
|
for (const nodeId of missingMediaNodeIds.value) {
|
|
for (const id of getAncestorExecutionIds(nodeId)) {
|
|
ids.add(id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
)
|
|
|
|
const activeMissingMediaGraphIds = computed<Set<string>>(() => {
|
|
if (!app.rootGraph) return new Set()
|
|
return getActiveGraphNodeIds(
|
|
app.rootGraph,
|
|
canvasStore.currentGraph ?? app.rootGraph,
|
|
missingMediaAncestorExecutionIds.value
|
|
)
|
|
})
|
|
|
|
// Interaction state — persists across component re-mounts
|
|
const expandState = ref<Record<string, boolean>>({})
|
|
const uploadState = ref<
|
|
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
|
|
>({})
|
|
/** Pending selection: value to apply on confirm. */
|
|
const pendingSelection = ref<Record<string, string>>({})
|
|
|
|
let _verificationAbortController: AbortController | null = null
|
|
|
|
function createVerificationAbortController(): AbortController {
|
|
_verificationAbortController?.abort()
|
|
_verificationAbortController = new AbortController()
|
|
return _verificationAbortController
|
|
}
|
|
|
|
function setMissingMedia(media: MissingMediaCandidate[]) {
|
|
missingMediaCandidates.value = media.length ? media : null
|
|
}
|
|
|
|
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
|
|
return missingMediaNodeIds.value.has(nodeLocatorId)
|
|
}
|
|
|
|
function isContainerWithMissingMedia(node: LGraphNode): boolean {
|
|
return activeMissingMediaGraphIds.value.has(String(node.id))
|
|
}
|
|
|
|
function clearInteractionStateForName(name: string) {
|
|
delete expandState.value[name]
|
|
delete uploadState.value[name]
|
|
delete pendingSelection.value[name]
|
|
}
|
|
|
|
function removeMissingMediaByName(name: string) {
|
|
if (!missingMediaCandidates.value) return
|
|
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
|
(m) => m.name !== name
|
|
)
|
|
clearInteractionStateForName(name)
|
|
if (!missingMediaCandidates.value.length)
|
|
missingMediaCandidates.value = null
|
|
}
|
|
|
|
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
|
|
if (!missingMediaCandidates.value) return
|
|
const removedNames = new Set(
|
|
missingMediaCandidates.value
|
|
.filter(
|
|
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
|
|
)
|
|
.map((m) => m.name)
|
|
)
|
|
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
|
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
|
|
)
|
|
for (const name of removedNames) {
|
|
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
|
clearInteractionStateForName(name)
|
|
}
|
|
}
|
|
if (!missingMediaCandidates.value.length)
|
|
missingMediaCandidates.value = null
|
|
}
|
|
|
|
function clearMissingMedia() {
|
|
_verificationAbortController?.abort()
|
|
_verificationAbortController = null
|
|
missingMediaCandidates.value = null
|
|
expandState.value = {}
|
|
uploadState.value = {}
|
|
pendingSelection.value = {}
|
|
}
|
|
|
|
return {
|
|
missingMediaCandidates,
|
|
hasMissingMedia,
|
|
missingMediaCount,
|
|
missingMediaNodeIds,
|
|
missingMediaAncestorExecutionIds,
|
|
activeMissingMediaGraphIds,
|
|
|
|
setMissingMedia,
|
|
removeMissingMediaByName,
|
|
removeMissingMediaByWidget,
|
|
clearMissingMedia,
|
|
createVerificationAbortController,
|
|
|
|
hasMissingMediaOnNode,
|
|
isContainerWithMissingMedia,
|
|
|
|
expandState,
|
|
uploadState,
|
|
pendingSelection
|
|
}
|
|
})
|