mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +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
319 lines
11 KiB
Vue
319 lines
11 KiB
Vue
<template>
|
|
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
|
|
<!-- File header -->
|
|
<div class="flex h-8 w-full items-center gap-2">
|
|
<i
|
|
aria-hidden="true"
|
|
class="text-foreground icon-[lucide--file] size-4 shrink-0"
|
|
/>
|
|
|
|
<!-- Single node: show node display name instead of filename -->
|
|
<template v-if="isSingleNode">
|
|
<span
|
|
v-if="showNodeIdBadge"
|
|
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
|
>
|
|
#{{ item.referencingNodes[0].nodeId }}
|
|
</span>
|
|
<p
|
|
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
|
:title="singleNodeLabel"
|
|
>
|
|
{{ singleNodeLabel }}
|
|
</p>
|
|
</template>
|
|
|
|
<!-- Multiple nodes: show filename with count -->
|
|
<p
|
|
v-else
|
|
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
|
:title="displayName"
|
|
>
|
|
{{ displayName }}
|
|
({{ item.referencingNodes.length }})
|
|
</p>
|
|
|
|
<!-- Confirm button (visible when pending selection exists) -->
|
|
<Button
|
|
data-testid="missing-media-confirm-button"
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
|
|
:disabled="!isPending"
|
|
:class="
|
|
cn(
|
|
'size-8 shrink-0 rounded-lg transition-colors',
|
|
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
|
|
)
|
|
"
|
|
@click="confirmSelection(item.name)"
|
|
>
|
|
<i
|
|
aria-hidden="true"
|
|
class="icon-[lucide--check] size-4"
|
|
:class="isPending ? 'text-primary' : 'text-foreground'"
|
|
/>
|
|
</Button>
|
|
|
|
<!-- Locate button (single node only) -->
|
|
<Button
|
|
v-if="isSingleNode"
|
|
data-testid="missing-media-locate-button"
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
|
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
|
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
|
|
>
|
|
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
|
</Button>
|
|
|
|
<!-- Expand button (multiple nodes only) -->
|
|
<Button
|
|
v-if="!isSingleNode"
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
:aria-label="
|
|
expanded
|
|
? t('rightSidePanel.missingMedia.collapseNodes')
|
|
: t('rightSidePanel.missingMedia.expandNodes')
|
|
"
|
|
:aria-expanded="expanded"
|
|
:class="
|
|
cn(
|
|
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
|
|
expanded && 'rotate-180'
|
|
)
|
|
"
|
|
@click="toggleExpand(item.name)"
|
|
>
|
|
<i
|
|
aria-hidden="true"
|
|
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
<!-- Referencing nodes (expandable) -->
|
|
<TransitionCollapse>
|
|
<div
|
|
v-if="expanded && item.referencingNodes.length > 1"
|
|
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
|
|
>
|
|
<div
|
|
v-for="nodeRef in item.referencingNodes"
|
|
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
|
|
class="flex h-7 items-center"
|
|
>
|
|
<span
|
|
v-if="showNodeIdBadge"
|
|
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
|
>
|
|
#{{ nodeRef.nodeId }}
|
|
</span>
|
|
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
|
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
|
|
</p>
|
|
<Button
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
|
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
|
@click="emit('locateNode', String(nodeRef.nodeId))"
|
|
>
|
|
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TransitionCollapse>
|
|
|
|
<!-- Status card (uploading, uploaded, or library select) -->
|
|
<TransitionCollapse>
|
|
<div
|
|
v-if="isPending || isUploading"
|
|
data-testid="missing-media-status-card"
|
|
role="status"
|
|
aria-live="polite"
|
|
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
|
|
>
|
|
<div class="relative z-10 flex items-center gap-2">
|
|
<div class="flex size-8 shrink-0 items-center justify-center">
|
|
<i
|
|
v-if="currentUpload?.status === 'uploading'"
|
|
aria-hidden="true"
|
|
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
|
|
/>
|
|
<i
|
|
v-else
|
|
aria-hidden="true"
|
|
class="icon-[lucide--file-check] size-5 text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex min-w-0 flex-1 flex-col justify-center">
|
|
<span class="text-foreground truncate text-xs/tight font-medium">
|
|
{{ pendingDisplayName }}
|
|
</span>
|
|
<span class="mt-0.5 text-xs/tight text-muted-foreground">
|
|
<template v-if="currentUpload?.status === 'uploading'">
|
|
{{ t('rightSidePanel.missingMedia.uploading') }}
|
|
</template>
|
|
<template v-else-if="currentUpload?.status === 'uploaded'">
|
|
{{ t('rightSidePanel.missingMedia.uploaded') }}
|
|
</template>
|
|
<template v-else>
|
|
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
|
|
</template>
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
data-testid="missing-media-cancel-button"
|
|
variant="textonly"
|
|
size="icon-sm"
|
|
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
|
|
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
|
@click="cancelSelection(item.name)"
|
|
>
|
|
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TransitionCollapse>
|
|
|
|
<!-- Upload + Library (when no pending selection) -->
|
|
<TransitionCollapse>
|
|
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
|
|
<!-- Upload dropzone -->
|
|
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
|
|
<button
|
|
data-testid="missing-media-upload-dropzone"
|
|
type="button"
|
|
:class="
|
|
cn(
|
|
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
|
|
isOverDropZone && 'border-primary text-primary'
|
|
)
|
|
"
|
|
@click="openFilePicker()"
|
|
>
|
|
{{
|
|
t('rightSidePanel.missingMedia.uploadFile', {
|
|
type: extensionHint
|
|
})
|
|
}}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- OR separator + Use from Library -->
|
|
<MissingMediaLibrarySelect
|
|
data-testid="missing-media-library-select"
|
|
:model-value="undefined"
|
|
:options="libraryOptions"
|
|
:show-divider="true"
|
|
:media-type="item.mediaType"
|
|
@select="handleLibrarySelect(item.name, $event)"
|
|
/>
|
|
</div>
|
|
</TransitionCollapse>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref } from 'vue'
|
|
import { useDropZone, useFileDialog } from '@vueuse/core'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
|
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
|
|
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
|
|
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
|
import {
|
|
useMissingMediaInteractions,
|
|
getNodeDisplayLabel,
|
|
getMediaDisplayName
|
|
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
|
|
|
|
const { item, showNodeIdBadge } = defineProps<{
|
|
item: MissingMediaViewModel
|
|
showNodeIdBadge: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
locateNode: [nodeId: string]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
|
|
const store = useMissingMediaStore()
|
|
const { uploadState, pendingSelection } = storeToRefs(store)
|
|
|
|
const {
|
|
isExpanded,
|
|
toggleExpand,
|
|
getAcceptType,
|
|
getExtensionHint,
|
|
getLibraryOptions,
|
|
handleLibrarySelect,
|
|
handleUpload,
|
|
confirmSelection,
|
|
cancelSelection,
|
|
hasPendingSelection
|
|
} = useMissingMediaInteractions()
|
|
|
|
const displayName = getMediaDisplayName(item.name)
|
|
const isSingleNode = item.referencingNodes.length === 1
|
|
const singleNodeLabel = isSingleNode
|
|
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
|
|
: ''
|
|
const acceptType = getAcceptType(item.mediaType)
|
|
const extensionHint = getExtensionHint(item.mediaType)
|
|
|
|
const expanded = computed(() => isExpanded(item.name))
|
|
const matchingCandidate = computed(() => {
|
|
const candidates = store.missingMediaCandidates
|
|
if (!candidates?.length) return null
|
|
return candidates.find((c) => c.name === item.name) ?? null
|
|
})
|
|
const libraryOptions = computed(() => {
|
|
const candidate = matchingCandidate.value
|
|
if (!candidate) return []
|
|
return getLibraryOptions(candidate)
|
|
})
|
|
|
|
const isPending = computed(() => hasPendingSelection(item.name))
|
|
const isUploading = computed(
|
|
() => uploadState.value[item.name]?.status === 'uploading'
|
|
)
|
|
const currentUpload = computed(() => uploadState.value[item.name])
|
|
const pendingDisplayName = computed(() => {
|
|
if (currentUpload.value) return currentUpload.value.fileName
|
|
const pending = pendingSelection.value[item.name]
|
|
return pending ? getMediaDisplayName(pending) : ''
|
|
})
|
|
|
|
const dropZoneRef = ref<HTMLElement | null>(null)
|
|
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
|
onDrop: (_files, event) => {
|
|
event?.stopPropagation()
|
|
const file = _files?.[0]
|
|
if (file) {
|
|
handleUpload(file, item.name, item.mediaType)
|
|
}
|
|
}
|
|
})
|
|
|
|
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
|
|
accept: acceptType,
|
|
multiple: false
|
|
})
|
|
onFileSelected((files) => {
|
|
const file = files?.[0]
|
|
if (file) {
|
|
handleUpload(file, item.name, item.mediaType)
|
|
}
|
|
})
|
|
</script>
|