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:
jaeone94
2026-03-19 19:41:30 +09:00
parent a44fa1fdd5
commit 39352c857b
14 changed files with 908 additions and 8 deletions

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -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()
)
},

View File

@@ -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": {

View 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>

View 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>

View 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)
})
})

View 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)!)
}))
}

View 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)
})
})

View 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
}
})

View 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[]
}

View File

@@ -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')

View File

@@ -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,