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
225 lines
6.3 KiB
TypeScript
225 lines
6.3 KiB
TypeScript
import { app } from '@/scripts/app'
|
|
import { api } from '@/scripts/api'
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
import { useAssetsStore } from '@/stores/assetsStore'
|
|
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import type {
|
|
MissingMediaCandidate,
|
|
MediaType
|
|
} from '@/platform/missingMedia/types'
|
|
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
|
|
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
|
import { st } from '@/i18n'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
|
import {
|
|
ACCEPTED_IMAGE_TYPES,
|
|
ACCEPTED_VIDEO_TYPES
|
|
} from '@/utils/mediaUploadUtil'
|
|
|
|
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
|
|
image: ACCEPTED_IMAGE_TYPES,
|
|
video: ACCEPTED_VIDEO_TYPES,
|
|
audio: 'audio/*'
|
|
}
|
|
|
|
function getMediaComboWidget(
|
|
candidate: MissingMediaCandidate
|
|
): { node: LGraphNode; widget: IComboWidget } | null {
|
|
const graph = app.rootGraph
|
|
if (!graph || candidate.nodeId == null) return null
|
|
|
|
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
|
|
if (!node) return null
|
|
|
|
const widget = node.widgets?.find(
|
|
(w) => w.name === candidate.widgetName && w.type === 'combo'
|
|
) as IComboWidget | undefined
|
|
if (!widget) return null
|
|
|
|
return { node, widget }
|
|
}
|
|
|
|
function resolveLibraryOptions(
|
|
candidate: MissingMediaCandidate
|
|
): { name: string; value: string }[] {
|
|
const result = getMediaComboWidget(candidate)
|
|
if (!result) return []
|
|
|
|
return resolveComboValues(result.widget)
|
|
.filter((v) => v !== candidate.name)
|
|
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
|
|
}
|
|
|
|
function applyValueToNodes(
|
|
candidates: MissingMediaCandidate[],
|
|
name: string,
|
|
newValue: string
|
|
) {
|
|
const matching = candidates.filter((c) => c.name === name)
|
|
for (const c of matching) {
|
|
const result = getMediaComboWidget(c)
|
|
if (!result) continue
|
|
|
|
addToComboValues(result.widget, newValue)
|
|
result.widget.value = newValue
|
|
result.widget.callback?.(newValue)
|
|
result.node.graph?.setDirtyCanvas(true, true)
|
|
}
|
|
}
|
|
|
|
export function getNodeDisplayLabel(
|
|
nodeId: string | number,
|
|
fallback: string
|
|
): string {
|
|
const graph = app.rootGraph
|
|
if (!graph) return fallback
|
|
const node = getNodeByExecutionId(graph, String(nodeId))
|
|
return resolveNodeDisplayName(node, {
|
|
emptyLabel: fallback,
|
|
untitledLabel: fallback,
|
|
st
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Resolve display name for a media file.
|
|
* Cloud widgets store asset hashes as values; this resolves them to
|
|
* human-readable names via assetsStore.getInputName().
|
|
*/
|
|
export function getMediaDisplayName(name: string): string {
|
|
if (!isCloud) return name
|
|
return useAssetsStore().getInputName(name)
|
|
}
|
|
|
|
export function useMissingMediaInteractions() {
|
|
const store = useMissingMediaStore()
|
|
const assetsStore = useAssetsStore()
|
|
|
|
function isExpanded(key: string): boolean {
|
|
return store.expandState[key] ?? false
|
|
}
|
|
|
|
function toggleExpand(key: string) {
|
|
store.expandState[key] = !isExpanded(key)
|
|
}
|
|
|
|
function getAcceptType(mediaType: MediaType): string {
|
|
return MEDIA_ACCEPT_MAP[mediaType]
|
|
}
|
|
|
|
function getExtensionHint(mediaType: MediaType): string {
|
|
if (mediaType === 'audio') return 'audio'
|
|
const exts = MEDIA_ACCEPT_MAP[mediaType]
|
|
.split(',')
|
|
.map((mime) => mime.split('/')[1])
|
|
.join(', ')
|
|
return `${exts}, ...`
|
|
}
|
|
|
|
function getLibraryOptions(
|
|
candidate: MissingMediaCandidate
|
|
): { name: string; value: string }[] {
|
|
return resolveLibraryOptions(candidate)
|
|
}
|
|
|
|
/** Step 1: Store selection from library (does not apply yet). */
|
|
function handleLibrarySelect(name: string, value: string) {
|
|
store.pendingSelection[name] = value
|
|
}
|
|
|
|
/** Step 1: Upload file and store result as pending (does not apply yet). */
|
|
async function handleUpload(file: File, name: string, mediaType: MediaType) {
|
|
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
|
|
useToastStore().addAlert(
|
|
st(
|
|
'toastMessages.unsupportedFileType',
|
|
'Unsupported file type. Please select a valid file.'
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
|
|
|
|
try {
|
|
const body = new FormData()
|
|
body.append('image', file)
|
|
|
|
const resp = await api.fetchApi('/upload/image', {
|
|
method: 'POST',
|
|
body
|
|
})
|
|
|
|
if (resp.status !== 200) {
|
|
useToastStore().addAlert(
|
|
st(
|
|
'toastMessages.uploadFailed',
|
|
'Failed to upload file. Please try again.'
|
|
)
|
|
)
|
|
delete store.uploadState[name]
|
|
return
|
|
}
|
|
|
|
const data = await resp.json()
|
|
const uploadedPath: string = data.subfolder
|
|
? `${data.subfolder}/${data.name}`
|
|
: data.name
|
|
|
|
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
|
|
store.pendingSelection[name] = uploadedPath
|
|
|
|
// Refresh assets store (non-critical — upload already succeeded)
|
|
try {
|
|
await assetsStore.updateInputs()
|
|
} catch {
|
|
// Asset list refresh failed but upload is valid; selection can proceed
|
|
}
|
|
} catch {
|
|
useToastStore().addAlert(
|
|
st(
|
|
'toastMessages.uploadFailed',
|
|
'Failed to upload file. Please try again.'
|
|
)
|
|
)
|
|
delete store.uploadState[name]
|
|
}
|
|
}
|
|
|
|
/** Step 2: Apply pending selection to widgets and remove from missing list. */
|
|
function confirmSelection(name: string) {
|
|
const value = store.pendingSelection[name]
|
|
if (!value || !store.missingMediaCandidates) return
|
|
|
|
applyValueToNodes(store.missingMediaCandidates, name, value)
|
|
store.removeMissingMediaByName(name)
|
|
delete store.pendingSelection[name]
|
|
delete store.uploadState[name]
|
|
}
|
|
|
|
function cancelSelection(name: string) {
|
|
delete store.pendingSelection[name]
|
|
delete store.uploadState[name]
|
|
}
|
|
|
|
function hasPendingSelection(name: string): boolean {
|
|
return name in store.pendingSelection
|
|
}
|
|
|
|
return {
|
|
isExpanded,
|
|
toggleExpand,
|
|
getAcceptType,
|
|
getExtensionHint,
|
|
getLibraryOptions,
|
|
handleLibrarySelect,
|
|
handleUpload,
|
|
confirmSelection,
|
|
cancelSelection,
|
|
hasPendingSelection
|
|
}
|
|
}
|