mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
feat: detect missing media inputs (image/video/audio) in error tab
Add a missing media detection pipeline that mirrors the existing missing model pipeline. When a workflow references LoadImage/LoadVideo/LoadAudio files that don't exist, they now appear in the Errors tab grouped by media type with locate-node actions. - OSS: sync detection via widget options - Cloud: async verification via assetsStore.updateInputs() - Integrates with error overlay, node error flags, and error counts
This commit is contained in:
@@ -170,6 +170,14 @@
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateModel"
|
||||
/>
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateModel"
|
||||
/>
|
||||
</PropertiesAccordionItem>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -225,6 +233,7 @@ 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,
|
||||
@@ -261,7 +270,8 @@ const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
const fullSizeGroupTypes = new Set([
|
||||
'missing_node',
|
||||
'swap_nodes',
|
||||
'missing_model'
|
||||
'missing_model',
|
||||
'missing_media'
|
||||
])
|
||||
function getGroupSize(group: ErrorGroup) {
|
||||
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
|
||||
@@ -283,6 +293,7 @@ const {
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
|
||||
@@ -25,3 +25,4 @@ export type ErrorGroup =
|
||||
| { type: 'missing_node'; title: string; priority: number }
|
||||
| { type: 'swap_nodes'; title: string; priority: number }
|
||||
| { type: 'missing_model'; title: string; priority: number }
|
||||
| { type: 'missing_media'; title: string; priority: number }
|
||||
|
||||
@@ -4,6 +4,7 @@ import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
@@ -29,7 +30,9 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
@@ -239,6 +242,7 @@ export function useErrorGroups(
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { inferPackFromNodeName } = useComfyRegistryStore()
|
||||
const collapseState = reactive<Record<string, boolean>>({})
|
||||
@@ -635,6 +639,27 @@ export function useErrorGroups(
|
||||
]
|
||||
}
|
||||
|
||||
const missingMediaGroups = computed<MissingMediaGroup[]>(() => {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
return groupCandidatesByMediaType(candidates)
|
||||
})
|
||||
|
||||
function buildMissingMediaGroups(): ErrorGroup[] {
|
||||
if (!missingMediaGroups.value.length) return []
|
||||
const totalItems = missingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -645,6 +670,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
@@ -663,6 +689,7 @@ export function useErrorGroups(
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
@@ -700,6 +727,7 @@ export function useErrorGroups(
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
@@ -32,7 +33,8 @@ function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
|
||||
function reconcileNodeErrorFlags(
|
||||
rootGraph: LGraph,
|
||||
nodeErrors: Record<string, NodeError> | null,
|
||||
missingModelExecIds: Set<string>
|
||||
missingModelExecIds: Set<string>,
|
||||
missingMediaExecIds: Set<string> = new Set()
|
||||
): void {
|
||||
// Collect nodes and slot info that should be flagged
|
||||
// Includes both error-owning nodes and their ancestor containers
|
||||
@@ -64,6 +66,11 @@ function reconcileNodeErrorFlags(
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
for (const execId of missingMediaExecIds) {
|
||||
const node = getNodeByExecutionId(rootGraph, execId)
|
||||
if (node) flaggedNodes.add(node)
|
||||
}
|
||||
|
||||
forEachNode(rootGraph, (node) => {
|
||||
setNodeHasErrors(node, flaggedNodes.has(node))
|
||||
|
||||
@@ -78,7 +85,8 @@ function reconcileNodeErrorFlags(
|
||||
|
||||
export function useNodeErrorFlagSync(
|
||||
lastNodeErrors: Ref<Record<string, NodeError> | null>,
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>
|
||||
missingModelStore: ReturnType<typeof useMissingModelStore>,
|
||||
missingMediaStore: ReturnType<typeof useMissingMediaStore>
|
||||
): () => void {
|
||||
const settingStore = useSettingStore()
|
||||
const showErrorsTab = computed(() =>
|
||||
@@ -89,12 +97,13 @@ export function useNodeErrorFlagSync(
|
||||
[
|
||||
lastNodeErrors,
|
||||
() => missingModelStore.missingModelNodeIds,
|
||||
() => missingMediaStore.missingMediaNodeIds,
|
||||
showErrorsTab
|
||||
],
|
||||
() => {
|
||||
if (!app.isGraphReady) return
|
||||
// Legacy (LGraphNode) only: suppress missing-model error flags when
|
||||
// the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// Legacy (LGraphNode) only: suppress missing-model/media error flags
|
||||
// when the Errors tab is hidden, since legacy nodes lack the per-widget
|
||||
// red highlight that Vue nodes use to indicate *why* a node has errors.
|
||||
// Vue nodes compute hasAnyError independently and are unaffected.
|
||||
reconcileNodeErrorFlags(
|
||||
@@ -102,6 +111,9 @@ export function useNodeErrorFlagSync(
|
||||
lastNodeErrors.value,
|
||||
showErrorsTab.value
|
||||
? missingModelStore.missingModelAncestorExecutionIds
|
||||
: new Set(),
|
||||
showErrorsTab.value
|
||||
? missingMediaStore.missingMediaAncestorExecutionIds
|
||||
: new Set()
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3526,6 +3526,13 @@
|
||||
"missingModelsTitle": "Missing Models",
|
||||
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
|
||||
"downloadAll": "Download all"
|
||||
},
|
||||
"missingMedia": {
|
||||
"missingMediaTitle": "Missing Media",
|
||||
"image": "Images",
|
||||
"video": "Videos",
|
||||
"audio": "Audio",
|
||||
"locateNode": "Locate node"
|
||||
}
|
||||
},
|
||||
"errorOverlay": {
|
||||
|
||||
65
src/platform/missingMedia/components/MissingMediaCard.vue
Normal file
65
src/platform/missingMedia/components/MissingMediaCard.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="px-4 pb-2">
|
||||
<div
|
||||
v-for="group in missingMediaGroups"
|
||||
:key="group.mediaType"
|
||||
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<!-- Media type header -->
|
||||
<div class="flex h-8 w-full items-center">
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="mediaTypeIcon(group.mediaType)"
|
||||
class="mr-1 size-3.5 align-text-bottom"
|
||||
/>
|
||||
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
|
||||
({{ group.items.length }})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media file rows -->
|
||||
<div class="flex flex-col gap-1 overflow-hidden pl-2">
|
||||
<MissingMediaRow
|
||||
v-for="item in group.items"
|
||||
:key="item.name"
|
||||
:item="item"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="emit('locateNode', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
|
||||
|
||||
const { missingMediaGroups } = defineProps<{
|
||||
missingMediaGroups: MissingMediaGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
|
||||
image: 'icon-[lucide--image]',
|
||||
video: 'icon-[lucide--video]',
|
||||
audio: 'icon-[lucide--music]'
|
||||
}
|
||||
|
||||
function mediaTypeIcon(mediaType: MediaType): string {
|
||||
return MEDIA_TYPE_ICONS[mediaType]
|
||||
}
|
||||
</script>
|
||||
92
src/platform/missingMedia/components/MissingMediaRow.vue
Normal file
92
src/platform/missingMedia/components/MissingMediaRow.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col pb-2">
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<p
|
||||
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
|
||||
:title="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
({{ item.referencingNodes.length }})
|
||||
</p>
|
||||
|
||||
<Button
|
||||
v-if="item.referencingNodes.length > 0"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="mr-1 size-6 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>
|
||||
</div>
|
||||
|
||||
<!-- Referencing nodes (when multiple) -->
|
||||
<div
|
||||
v-if="item.referencingNodes.length > 1"
|
||||
class="flex flex-col gap-0.5 overflow-hidden pl-6"
|
||||
>
|
||||
<div
|
||||
v-for="ref in item.referencingNodes"
|
||||
:key="`${String(ref.nodeId)}::${ref.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"
|
||||
>
|
||||
#{{ ref.nodeId }}
|
||||
</span>
|
||||
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
|
||||
{{ getNodeDisplayLabel(ref.nodeId) }}
|
||||
</p>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
|
||||
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="emit('locateNode', String(ref.nodeId))"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
item: MissingMediaViewModel
|
||||
showNodeIdBadge: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function getNodeDisplayLabel(nodeId: NodeId): string {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, String(nodeId))
|
||||
return resolveNodeDisplayName(graphNode, {
|
||||
emptyLabel: '',
|
||||
untitledLabel: '',
|
||||
st
|
||||
})
|
||||
}
|
||||
</script>
|
||||
201
src/platform/missingMedia/missingMediaScan.test.ts
Normal file
201
src/platform/missingMedia/missingMediaScan.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
verifyCloudMediaCandidates,
|
||||
groupCandidatesByName,
|
||||
groupCandidatesByMediaType,
|
||||
MEDIA_NODE_WIDGETS
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
describe('MEDIA_NODE_WIDGETS', () => {
|
||||
it('maps LoadImage, LoadVideo, LoadAudio', () => {
|
||||
expect(MEDIA_NODE_WIDGETS.LoadImage).toEqual({
|
||||
widgetName: 'image',
|
||||
mediaType: 'image'
|
||||
})
|
||||
expect(MEDIA_NODE_WIDGETS.LoadVideo).toEqual({
|
||||
widgetName: 'file',
|
||||
mediaType: 'video'
|
||||
})
|
||||
expect(MEDIA_NODE_WIDGETS.LoadAudio).toEqual({
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupCandidatesByName', () => {
|
||||
it('groups candidates with the same name', () => {
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'other.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
|
||||
const result = groupCandidatesByName(candidates)
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
|
||||
const otherGroup = result.find((g) => g.name === 'other.png')
|
||||
expect(otherGroup?.referencingNodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(groupCandidatesByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupCandidatesByMediaType', () => {
|
||||
it('groups by media type in order: image, video, audio', () => {
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadAudio',
|
||||
widgetName: 'audio',
|
||||
mediaType: 'audio',
|
||||
name: 'sound.mp3',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
},
|
||||
{
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
name: 'clip.mp4',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
|
||||
const result = groupCandidatesByMediaType(candidates)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].mediaType).toBe('image')
|
||||
expect(result[1].mediaType).toBe('video')
|
||||
expect(result[2].mediaType).toBe('audio')
|
||||
})
|
||||
|
||||
it('omits media types with no candidates', () => {
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadVideo',
|
||||
widgetName: 'file',
|
||||
mediaType: 'video',
|
||||
name: 'clip.mp4',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
|
||||
const result = groupCandidatesByMediaType(candidates)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].mediaType).toBe('video')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyCloudMediaCandidates', () => {
|
||||
it('marks candidates missing when not in input assets', async () => {
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'abc123.png',
|
||||
isMissing: undefined
|
||||
},
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'def456.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: [{ asset_hash: 'def456.png', name: 'my-photo.png' }]
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
expect(candidates[1].isMissing).toBe(false)
|
||||
})
|
||||
|
||||
it('respects abort signal', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'abc123.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
]
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
|
||||
// Should not have been modified
|
||||
expect(candidates[0].isMissing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips candidates already resolved', async () => {
|
||||
const candidates: MissingMediaCandidate[] = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'abc123.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
|
||||
const mockStore = {
|
||||
updateInputs: async () => {},
|
||||
inputAssets: []
|
||||
}
|
||||
|
||||
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
|
||||
|
||||
// Already resolved, should remain unchanged
|
||||
expect(candidates[0].isMissing).toBe(true)
|
||||
})
|
||||
})
|
||||
171
src/platform/missingMedia/missingMediaScan.ts
Normal file
171
src/platform/missingMedia/missingMediaScan.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type {
|
||||
MissingMediaCandidate,
|
||||
MissingMediaViewModel,
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from './types'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
export const MEDIA_NODE_WIDGETS: Record<
|
||||
string,
|
||||
{ widgetName: string; mediaType: MediaType }
|
||||
> = {
|
||||
LoadImage: { widgetName: 'image', mediaType: 'image' },
|
||||
LoadVideo: { widgetName: 'file', mediaType: 'video' },
|
||||
LoadAudio: { widgetName: 'audio', mediaType: 'audio' }
|
||||
}
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
return widget.type === 'combo'
|
||||
}
|
||||
|
||||
function resolveComboOptions(widget: IComboWidget): string[] {
|
||||
const values = widget.options.values
|
||||
if (!values) return []
|
||||
if (typeof values === 'function') return values(widget)
|
||||
if (Array.isArray(values)) return values
|
||||
return Object.keys(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan combo widgets on media nodes for file values that may be missing.
|
||||
*
|
||||
* OSS: `isMissing` resolved immediately via widget options.
|
||||
* Cloud: `isMissing` left `undefined` for async verification.
|
||||
*/
|
||||
export function scanAllMediaCandidates(
|
||||
rootGraph: LGraph,
|
||||
isCloud: boolean
|
||||
): MissingMediaCandidate[] {
|
||||
if (!rootGraph) return []
|
||||
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
const candidates: MissingMediaCandidate[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (!node.widgets?.length) continue
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
|
||||
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
||||
if (!mediaInfo) continue
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
if (!isComboWidget(widget)) continue
|
||||
if (widget.name !== mediaInfo.widgetName) continue
|
||||
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) continue
|
||||
|
||||
let isMissing: boolean | undefined
|
||||
if (isCloud) {
|
||||
// Cloud: options may be empty initially; defer to async verification
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboOptions(widget)
|
||||
isMissing = !options.includes(value)
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
mediaType: mediaInfo.mediaType,
|
||||
name: value,
|
||||
isMissing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
interface InputVerifier {
|
||||
updateInputs: () => Promise<unknown>
|
||||
inputAssets: Array<{ asset_hash?: string | null; name: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify cloud media candidates against the input assets fetched from the
|
||||
* assets store. Mutates candidates' `isMissing` in place.
|
||||
*/
|
||||
export async function verifyCloudMediaCandidates(
|
||||
candidates: MissingMediaCandidate[],
|
||||
signal?: AbortSignal,
|
||||
assetsStore?: InputVerifier
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) return
|
||||
|
||||
const pending = candidates.filter((c) => c.isMissing === undefined)
|
||||
if (pending.length === 0) return
|
||||
|
||||
const store =
|
||||
assetsStore ?? (await import('@/stores/assetsStore')).useAssetsStore()
|
||||
|
||||
await store.updateInputs()
|
||||
|
||||
if (signal?.aborted) return
|
||||
|
||||
const assetHashes = new Set(
|
||||
store.inputAssets.map((a) => a.asset_hash).filter((h): h is string => !!h)
|
||||
)
|
||||
|
||||
for (const c of pending) {
|
||||
c.isMissing = !assetHashes.has(c.name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by file name into view models. */
|
||||
export function groupCandidatesByName(
|
||||
candidates: MissingMediaCandidate[]
|
||||
): MissingMediaViewModel[] {
|
||||
const map = new Map<string, MissingMediaViewModel>()
|
||||
for (const c of candidates) {
|
||||
const existing = map.get(c.name)
|
||||
if (existing) {
|
||||
existing.referencingNodes.push({
|
||||
nodeId: c.nodeId,
|
||||
widgetName: c.widgetName
|
||||
})
|
||||
} else {
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
/** Group confirmed-missing candidates by media type. */
|
||||
export function groupCandidatesByMediaType(
|
||||
candidates: MissingMediaCandidate[]
|
||||
): MissingMediaGroup[] {
|
||||
const typeMap = new Map<MediaType, MissingMediaCandidate[]>()
|
||||
for (const c of candidates) {
|
||||
const list = typeMap.get(c.mediaType)
|
||||
if (list) list.push(c)
|
||||
else typeMap.set(c.mediaType, [c])
|
||||
}
|
||||
|
||||
const order: MediaType[] = ['image', 'video', 'audio']
|
||||
return order
|
||||
.filter((t) => typeMap.has(t))
|
||||
.map((mediaType) => ({
|
||||
mediaType,
|
||||
items: groupCandidatesByName(typeMap.get(mediaType)!)
|
||||
}))
|
||||
}
|
||||
113
src/platform/missingMedia/missingMediaStore.test.ts
Normal file
113
src/platform/missingMedia/missingMediaStore.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useMissingMediaStore } from './missingMediaStore'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
currentGraph: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getActiveGraphNodeIds: () => new Set<string>()
|
||||
}))
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
mediaType: 'image' | 'video' | 'audio' = 'image'
|
||||
): MissingMediaCandidate {
|
||||
return {
|
||||
nodeId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType,
|
||||
name,
|
||||
isMissing: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMissingMediaStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('starts with no missing media', () => {
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
})
|
||||
|
||||
it('setMissingMedia populates candidates', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const candidates = [makeCandidate('1', 'photo.png')]
|
||||
|
||||
store.setMissingMedia(candidates)
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingMedia).toBe(true)
|
||||
expect(store.missingMediaCount).toBe(1)
|
||||
})
|
||||
|
||||
it('setMissingMedia with empty array clears state', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
store.setMissingMedia([])
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('clearMissingMedia resets all state', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
|
||||
store.clearMissingMedia()
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
expect(store.missingMediaCount).toBe(0)
|
||||
})
|
||||
|
||||
it('missingMediaNodeIds tracks unique node IDs', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('1', 'other.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
|
||||
expect(store.missingMediaNodeIds.size).toBe(2)
|
||||
expect(store.missingMediaNodeIds.has('1')).toBe(true)
|
||||
expect(store.missingMediaNodeIds.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('hasMissingMediaOnNode checks node presence', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('42', 'photo.png')])
|
||||
|
||||
expect(store.hasMissingMediaOnNode('42')).toBe(true)
|
||||
expect(store.hasMissingMediaOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('createVerificationAbortController aborts previous controller', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const first = store.createVerificationAbortController()
|
||||
expect(first.signal.aborted).toBe(false)
|
||||
|
||||
store.createVerificationAbortController()
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
104
src/platform/missingMedia/missingMediaStore.ts
Normal file
104
src/platform/missingMedia/missingMediaStore.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!missingMediaCandidates.value) return ids
|
||||
for (const m of missingMediaCandidates.value) {
|
||||
ids.add(String(m.nodeId))
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
})
|
||||
|
||||
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 clearMissingMedia() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
missingMediaCandidates,
|
||||
hasMissingMedia,
|
||||
missingMediaCount,
|
||||
missingMediaNodeIds,
|
||||
missingMediaAncestorExecutionIds,
|
||||
activeMissingMediaGraphIds,
|
||||
|
||||
setMissingMedia,
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
})
|
||||
38
src/platform/missingMedia/types.ts
Normal file
38
src/platform/missingMedia/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'audio'
|
||||
|
||||
/**
|
||||
* A single (node, widget, media file) binding detected by the missing media pipeline.
|
||||
* The same file name may appear multiple times across different nodes.
|
||||
*/
|
||||
export interface MissingMediaCandidate {
|
||||
nodeId: NodeId
|
||||
nodeType: string
|
||||
widgetName: string
|
||||
mediaType: MediaType
|
||||
/** Display name (plain filename for OSS, asset hash for cloud). */
|
||||
name: string
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed present
|
||||
* - `undefined` — pending async verification (cloud only)
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single file name. */
|
||||
export interface MissingMediaViewModel {
|
||||
name: string
|
||||
mediaType: MediaType
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** A group of missing media items sharing the same media type. */
|
||||
export interface MissingMediaGroup {
|
||||
mediaType: MediaType
|
||||
items: MissingMediaViewModel[]
|
||||
}
|
||||
@@ -92,6 +92,11 @@ import {
|
||||
verifyAssetSupportedCandidates
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
|
||||
@@ -1137,6 +1142,7 @@ export class ComfyApp {
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
useMissingModelStore().clearMissingModels()
|
||||
useMissingMediaStore().clearMissingMedia()
|
||||
|
||||
if (clean !== false) {
|
||||
// Reset canvas context before configuring a new graph so subgraph UI
|
||||
@@ -1416,6 +1422,8 @@ export class ComfyApp {
|
||||
showMissingModels
|
||||
)
|
||||
|
||||
this.runMissingMediaPipeline()
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
}
|
||||
@@ -1565,6 +1573,36 @@ export class ComfyApp {
|
||||
return { missingModels }
|
||||
}
|
||||
|
||||
private runMissingMediaPipeline(): void {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
|
||||
if (!candidates.length) return
|
||||
|
||||
if (isCloud) {
|
||||
const controller = missingMediaStore.createVerificationAbortController()
|
||||
verifyCloudMediaCandidates(candidates, controller.signal)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
'[Missing Media Pipeline] Asset verification failed:',
|
||||
err
|
||||
)
|
||||
})
|
||||
} else {
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async graphToPrompt(graph = this.rootGraph) {
|
||||
return graphToPrompt(graph, {
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
|
||||
@@ -4,7 +4,9 @@ import { computed, ref } from 'vue'
|
||||
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -34,6 +36,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
@@ -170,6 +173,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Set missing media and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingMedia(media: MissingMediaCandidate[]) {
|
||||
missingMediaStore.setMissingMedia(media)
|
||||
if (
|
||||
media.length &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
@@ -197,7 +211,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
hasPromptError.value ||
|
||||
hasNodeError.value ||
|
||||
missingNodesStore.hasMissingNodes ||
|
||||
missingModelStore.hasMissingModels
|
||||
missingModelStore.hasMissingModels ||
|
||||
missingMediaStore.hasMissingMedia
|
||||
)
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
@@ -233,7 +248,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
nodeErrorCount.value +
|
||||
executionErrorCount.value +
|
||||
missingNodesStore.missingNodeCount +
|
||||
missingModelStore.missingModelCount
|
||||
missingModelStore.missingModelCount +
|
||||
missingMediaStore.missingMediaCount
|
||||
)
|
||||
|
||||
/** Graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
@@ -326,7 +342,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
|
||||
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
|
||||
|
||||
return {
|
||||
// Raw state
|
||||
@@ -360,6 +376,9 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
// Missing model coordination (delegates to missingModelStore)
|
||||
surfaceMissingModels,
|
||||
|
||||
// Missing media coordination (delegates to missingMediaStore)
|
||||
surfaceMissingMedia,
|
||||
|
||||
// Lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
|
||||
Reference in New Issue
Block a user