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
148 lines
4.3 KiB
Vue
148 lines
4.3 KiB
Vue
<template>
|
|
<div class="flex flex-col gap-2">
|
|
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
|
|
<span class="text-xs font-bold text-muted-foreground">
|
|
{{ t('rightSidePanel.missingMedia.or') }}
|
|
</span>
|
|
</div>
|
|
|
|
<Select
|
|
:model-value="modelValue"
|
|
:disabled="options.length === 0"
|
|
@update:model-value="handleSelect"
|
|
>
|
|
<SelectTrigger
|
|
size="md"
|
|
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
|
|
>
|
|
<SelectValue
|
|
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
|
|
/>
|
|
</SelectTrigger>
|
|
|
|
<SelectContent class="max-h-72">
|
|
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
|
|
<div class="px-1 pb-1.5">
|
|
<div
|
|
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
|
|
>
|
|
<i
|
|
aria-hidden="true"
|
|
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
|
|
/>
|
|
<input
|
|
v-model="filterQuery"
|
|
type="text"
|
|
:aria-label="t('g.searchPlaceholder', { subject: '' })"
|
|
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
|
:placeholder="t('g.searchPlaceholder', { subject: '' })"
|
|
@keydown.stop
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<SelectItem
|
|
v-for="option in filteredOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
class="text-xs"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<img
|
|
v-if="mediaType === 'image'"
|
|
:src="getPreviewUrl(option.value)"
|
|
alt=""
|
|
class="size-8 shrink-0 rounded-sm object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<video
|
|
v-else-if="mediaType === 'video'"
|
|
aria-hidden="true"
|
|
:src="getPreviewUrl(option.value)"
|
|
class="size-8 shrink-0 rounded-sm object-cover"
|
|
preload="metadata"
|
|
muted
|
|
/>
|
|
<i
|
|
v-else
|
|
aria-hidden="true"
|
|
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
|
|
/>
|
|
<span class="min-w-0 truncate">{{ option.name }}</span>
|
|
</div>
|
|
</SelectItem>
|
|
<div
|
|
v-if="filteredOptions.length === 0"
|
|
role="status"
|
|
class="px-3 py-2 text-xs text-muted-foreground"
|
|
>
|
|
{{ t('g.noResultsFound') }}
|
|
</div>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useFuse } from '@vueuse/integrations/useFuse'
|
|
import Select from '@/components/ui/select/Select.vue'
|
|
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
|
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
|
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
|
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
|
import type { MediaType } from '@/platform/missingMedia/types'
|
|
import { api } from '@/scripts/api'
|
|
|
|
const {
|
|
options,
|
|
showDivider = false,
|
|
mediaType
|
|
} = defineProps<{
|
|
modelValue: string | undefined
|
|
options: { name: string; value: string }[]
|
|
showDivider?: boolean
|
|
mediaType: MediaType
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [value: string]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const SEARCH_THRESHOLD = 4
|
|
const filterQuery = ref('')
|
|
|
|
watch(
|
|
() => options.length,
|
|
(len) => {
|
|
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
|
|
}
|
|
)
|
|
|
|
const { results: fuseResults } = useFuse(filterQuery, () => options, {
|
|
fuseOptions: {
|
|
keys: ['name'],
|
|
threshold: 0.4,
|
|
ignoreLocation: true
|
|
},
|
|
matchAllWhenSearchEmpty: true
|
|
})
|
|
|
|
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
|
|
|
|
function getPreviewUrl(filename: string): string {
|
|
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
|
|
}
|
|
|
|
function handleSelect(value: unknown) {
|
|
if (typeof value === 'string') {
|
|
filterQuery.value = ''
|
|
emit('select', value)
|
|
}
|
|
}
|
|
</script>
|