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
433 lines
14 KiB
Vue
433 lines
14 KiB
Vue
<template>
|
|
<div class="flex h-full min-w-0 flex-col">
|
|
<!-- Search bar + collapse toggle -->
|
|
<div
|
|
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
|
|
>
|
|
<FormSearchInput v-model="searchQuery" class="flex-1" />
|
|
<CollapseToggleButton
|
|
v-model="isAllCollapsed"
|
|
:show="!isSearching && tabErrorGroups.length > 1"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Runtime error: full-height panel outside accordion -->
|
|
<div
|
|
v-if="singleRuntimeErrorCard"
|
|
data-testid="runtime-error-panel"
|
|
aria-live="polite"
|
|
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
|
>
|
|
<div
|
|
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
|
>
|
|
{{ singleRuntimeErrorGroup?.title }}
|
|
</div>
|
|
<ErrorNodeCard
|
|
:key="singleRuntimeErrorCard.id"
|
|
:card="singleRuntimeErrorCard"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
full-height
|
|
class="min-h-0 flex-1"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Scrollable content (non-runtime or mixed errors) -->
|
|
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
|
<TransitionGroup tag="div" name="list-scale" class="relative">
|
|
<div
|
|
v-if="filteredGroups.length === 0"
|
|
key="empty"
|
|
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
|
|
>
|
|
{{
|
|
searchQuery.trim()
|
|
? t('rightSidePanel.noneSearchDesc')
|
|
: t('rightSidePanel.noErrors')
|
|
}}
|
|
</div>
|
|
|
|
<!-- Group by Class Type -->
|
|
<PropertiesAccordionItem
|
|
v-for="group in filteredGroups"
|
|
:key="group.title"
|
|
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
|
|
:collapse="isSectionCollapsed(group.title) && !isSearching"
|
|
class="border-b border-interface-stroke"
|
|
:size="getGroupSize(group)"
|
|
@update:collapse="setSectionCollapsed(group.title, $event)"
|
|
>
|
|
<template #label>
|
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
<span class="flex min-w-0 flex-1 items-center gap-2">
|
|
<i
|
|
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
|
|
/>
|
|
<span class="truncate text-destructive-background-hover">
|
|
{{
|
|
group.type === 'missing_node'
|
|
? `${group.title} (${missingPackGroups.length})`
|
|
: group.type === 'swap_nodes'
|
|
? `${group.title} (${swapNodeGroups.length})`
|
|
: group.title
|
|
}}
|
|
</span>
|
|
<span
|
|
v-if="group.type === 'execution' && group.cards.length > 1"
|
|
class="text-destructive-background-hover"
|
|
>
|
|
({{ group.cards.length }})
|
|
</span>
|
|
</span>
|
|
<Button
|
|
v-if="
|
|
group.type === 'missing_node' &&
|
|
missingNodePacks.length > 0 &&
|
|
shouldShowInstallButton
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
:disabled="isInstallingAll"
|
|
@click.stop="installAll"
|
|
>
|
|
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
|
|
{{
|
|
isInstallingAll
|
|
? t('rightSidePanel.missingNodePacks.installing')
|
|
: t('rightSidePanel.missingNodePacks.installAll')
|
|
}}
|
|
</Button>
|
|
<Button
|
|
v-else-if="
|
|
group.type === 'missing_model' &&
|
|
downloadableModels.length > 0
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
@click.stop="downloadAllModels"
|
|
>
|
|
{{ downloadAllLabel }}
|
|
</Button>
|
|
<Button
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
v-tooltip.top="
|
|
t(
|
|
'nodeReplacement.replaceAllWarning',
|
|
'Replaces all available nodes in this group.'
|
|
)
|
|
"
|
|
variant="secondary"
|
|
size="sm"
|
|
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
|
|
@click.stop="handleReplaceAll()"
|
|
>
|
|
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Missing Node Packs -->
|
|
<MissingNodeCard
|
|
v-if="group.type === 'missing_node'"
|
|
:show-info-button="shouldShowManagerButtons"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:missing-pack-groups="missingPackGroups"
|
|
@locate-node="handleLocateMissingNode"
|
|
@open-manager-info="handleOpenManagerInfo"
|
|
/>
|
|
|
|
<!-- Swap Nodes -->
|
|
<SwapNodesCard
|
|
v-else-if="group.type === 'swap_nodes'"
|
|
:swap-node-groups="swapNodeGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateMissingNode"
|
|
@replace="handleReplaceGroup"
|
|
/>
|
|
|
|
<!-- Execution Errors -->
|
|
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
|
<ErrorNodeCard
|
|
v-for="card in group.cards"
|
|
:key="card.id"
|
|
:card="card"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
:compact="isSingleNodeSelected"
|
|
@locate-node="handleLocateNode"
|
|
@enter-subgraph="handleEnterSubgraph"
|
|
@copy-to-clipboard="copyToClipboard"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Missing Models -->
|
|
<MissingModelCard
|
|
v-else-if="group.type === 'missing_model'"
|
|
:missing-model-groups="missingModelGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-model="handleLocateAssetNode"
|
|
/>
|
|
|
|
<!-- Missing Media -->
|
|
<MissingMediaCard
|
|
v-else-if="group.type === 'missing_media'"
|
|
:missing-media-groups="missingMediaGroups"
|
|
:show-node-id-badge="showNodeIdBadge"
|
|
@locate-node="handleLocateAssetNode"
|
|
/>
|
|
</PropertiesAccordionItem>
|
|
</TransitionGroup>
|
|
</div>
|
|
|
|
<!-- Fixed Footer: Help Links -->
|
|
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
|
|
<i18n-t
|
|
keypath="rightSidePanel.errorHelp"
|
|
tag="p"
|
|
class="m-0 text-sm/tight wrap-break-word text-muted-foreground"
|
|
>
|
|
<template #github>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="openGitHubIssues"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpGithub') }}
|
|
</Button>
|
|
</template>
|
|
<template #support>
|
|
<Button
|
|
variant="textonly"
|
|
size="unset"
|
|
class="inline text-sm whitespace-nowrap text-inherit underline"
|
|
@click="contactSupport"
|
|
>
|
|
{{ t('rightSidePanel.errorHelpSupport') }}
|
|
</Button>
|
|
</template>
|
|
</i18n-t>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
|
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
|
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
|
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
|
|
import { NodeBadgeMode } from '@/types/nodeSource'
|
|
|
|
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
|
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
|
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
|
import ErrorNodeCard from './ErrorNodeCard.vue'
|
|
import MissingNodeCard from './MissingNodeCard.vue'
|
|
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
|
|
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
|
|
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import {
|
|
downloadModel,
|
|
isModelDownloadable
|
|
} from '@/platform/missingModel/missingModelDownload'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { formatSize } from '@/utils/formatUtil'
|
|
import Button from '@/components/ui/button/Button.vue'
|
|
import DotSpinner from '@/components/common/DotSpinner.vue'
|
|
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
|
|
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
|
|
import { useErrorActions } from './useErrorActions'
|
|
import { useErrorGroups } from './useErrorGroups'
|
|
import type { SwapNodeGroup } from './useErrorGroups'
|
|
import type { ErrorGroup } from './types'
|
|
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
|
|
|
const { t } = useI18n()
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
const { focusNode, enterSubgraph } = useFocusNode()
|
|
const { openGitHubIssues, contactSupport } = useErrorActions()
|
|
const settingStore = useSettingStore()
|
|
const rightSidePanelStore = useRightSidePanelStore()
|
|
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
|
|
useManagerState()
|
|
const { missingNodePacks } = useMissingNodes()
|
|
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
|
usePackInstall(() => missingNodePacks.value)
|
|
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
|
|
|
const searchQuery = ref('')
|
|
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
|
|
|
const fullSizeGroupTypes = new Set([
|
|
'missing_node',
|
|
'swap_nodes',
|
|
'missing_model',
|
|
'missing_media'
|
|
])
|
|
function getGroupSize(group: ErrorGroup) {
|
|
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
|
}
|
|
|
|
const showNodeIdBadge = computed(
|
|
() =>
|
|
(settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode) !==
|
|
NodeBadgeMode.None
|
|
)
|
|
|
|
const {
|
|
allErrorGroups,
|
|
tabErrorGroups,
|
|
filteredGroups,
|
|
collapseState,
|
|
isSingleNodeSelected,
|
|
errorNodeCache,
|
|
missingNodeCache,
|
|
missingPackGroups,
|
|
missingModelGroups,
|
|
missingMediaGroups,
|
|
swapNodeGroups
|
|
} = useErrorGroups(searchQuery, t)
|
|
|
|
const singleRuntimeErrorGroup = computed(() => {
|
|
if (filteredGroups.value.length !== 1) return null
|
|
const group = filteredGroups.value[0]
|
|
const isSoleRuntimeError =
|
|
group.type === 'execution' &&
|
|
group.cards.length === 1 &&
|
|
group.cards[0].errors.every((e) => e.isRuntimeError)
|
|
return isSoleRuntimeError ? group : null
|
|
})
|
|
|
|
const singleRuntimeErrorCard = computed(
|
|
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
|
)
|
|
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
const downloadableModels = computed(() => {
|
|
if (isCloud) return []
|
|
return missingModelGroups.value.flatMap((group) =>
|
|
group.models
|
|
.filter(
|
|
(m) =>
|
|
m.representative.url &&
|
|
m.representative.directory &&
|
|
isModelDownloadable({
|
|
name: m.representative.name,
|
|
url: m.representative.url,
|
|
directory: m.representative.directory
|
|
})
|
|
)
|
|
.map((m) => ({
|
|
name: m.representative.name,
|
|
url: m.representative.url!,
|
|
directory: m.representative.directory!
|
|
}))
|
|
)
|
|
})
|
|
|
|
const downloadAllLabel = computed(() => {
|
|
const base = t('rightSidePanel.missingModels.downloadAll')
|
|
const total = downloadableModels.value.reduce(
|
|
(sum, m) => sum + (missingModelStore.fileSizes[m.url] ?? 0),
|
|
0
|
|
)
|
|
return total > 0 ? `${base} (${formatSize(total)})` : base
|
|
})
|
|
|
|
function downloadAllModels() {
|
|
for (const model of downloadableModels.value) {
|
|
downloadModel(model, missingModelStore.folderPaths)
|
|
}
|
|
}
|
|
|
|
const isAllCollapsed = computed({
|
|
get() {
|
|
return filteredGroups.value.every((g) => isSectionCollapsed(g.title))
|
|
},
|
|
set(collapse: boolean) {
|
|
for (const group of tabErrorGroups.value) {
|
|
setSectionCollapsed(group.title, collapse)
|
|
}
|
|
}
|
|
})
|
|
|
|
function isSectionCollapsed(title: string): boolean {
|
|
// Defaults to expanded when not explicitly set by the user
|
|
return collapseState[title] ?? false
|
|
}
|
|
|
|
function setSectionCollapsed(title: string, collapsed: boolean) {
|
|
collapseState[title] = collapsed
|
|
}
|
|
|
|
/**
|
|
* When an external trigger (e.g. "See Error" button in SectionWidgets)
|
|
* sets focusedErrorNodeId, expand only the group containing the target
|
|
* node and collapse all others so the user sees the relevant errors
|
|
* immediately.
|
|
*/
|
|
watch(
|
|
() => rightSidePanelStore.focusedErrorNodeId,
|
|
(graphNodeId) => {
|
|
if (!graphNodeId) return
|
|
const prefix = `${graphNodeId}:`
|
|
for (const group of allErrorGroups.value) {
|
|
if (group.type !== 'execution') continue
|
|
|
|
const hasMatch = group.cards.some(
|
|
(card) =>
|
|
card.graphNodeId === graphNodeId ||
|
|
(card.nodeId?.startsWith(prefix) ?? false)
|
|
)
|
|
setSectionCollapsed(group.title, !hasMatch)
|
|
}
|
|
rightSidePanelStore.focusedErrorNodeId = null
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function handleLocateNode(nodeId: string) {
|
|
focusNode(nodeId, errorNodeCache.value)
|
|
}
|
|
|
|
function handleLocateMissingNode(nodeId: string) {
|
|
focusNode(nodeId, missingNodeCache.value)
|
|
}
|
|
|
|
function handleLocateAssetNode(nodeId: string) {
|
|
focusNode(nodeId)
|
|
}
|
|
|
|
function handleOpenManagerInfo(packId: string) {
|
|
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
|
|
if (isKnownToRegistry) {
|
|
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
|
|
} else {
|
|
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
|
|
}
|
|
}
|
|
|
|
function handleReplaceGroup(group: SwapNodeGroup) {
|
|
replaceGroup(group)
|
|
}
|
|
|
|
function handleReplaceAll() {
|
|
replaceAllGroups(swapNodeGroups.value)
|
|
}
|
|
|
|
function handleEnterSubgraph(nodeId: string) {
|
|
enterSubgraph(nodeId, errorNodeCache.value)
|
|
}
|
|
</script>
|