feat: detect and resolve missing media inputs in error tab (#10309)

## 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
This commit is contained in:
jaeone94
2026-04-01 17:59:02 +09:00
committed by GitHub
parent bb96e3c95c
commit d9466947b2
27 changed files with 2075 additions and 76 deletions

View File

@@ -0,0 +1,68 @@
{
"last_node_id": 12,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_aaa.png", "image"]
},
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"size": [315, 314],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_bbb.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -44,7 +44,15 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
missingModelsGroup: 'error-group-missing-model',
missingMediaGroup: 'error-group-missing-media',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'

View File

@@ -0,0 +1,225 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function loadMissingMediaAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow = 'missing/missing_media_single'
) {
await comfyPage.workflow.loadWorkflow(workflow)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
test.describe('Missing media inputs in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Detection', () => {
test('Shows error overlay when workflow has missing media inputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
const messages = errorOverlay.getByTestId(
TestIds.dialogs.errorOverlayMessages
)
await expect(messages).toBeVisible()
await expect(messages).toHaveText(/missing required inputs/i)
})
test('Shows missing media group in errors tab after clicking See Errors', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeVisible()
})
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
).toBeVisible()
})
test('Does not show error overlay when all media inputs exist', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
})
})
test.describe('Upload flow (2-step confirm)', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow (2-step confirm)', () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).not.toBeVisible()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
.click()
await expect(getStatusCard(comfyPage)).not.toBeVisible()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).not.toBeVisible()
})
})
test.describe('Locate node', () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await loadMissingMediaAndOpenErrorsTab(comfyPage)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLocateButton
)
await expect(locateButton).toBeVisible()
await locateButton.click()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})
})

View File

@@ -15,6 +15,7 @@
<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
@@ -168,7 +169,15 @@
v-else-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-model="handleLocateModel"
@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>
@@ -225,6 +234,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 +271,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 +294,7 @@ const {
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery, t)
@@ -393,7 +405,7 @@ function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateModel(nodeId: string) {
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}

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

@@ -20,7 +20,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
return commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {

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
]
})
@@ -699,6 +726,7 @@ export function useErrorGroups(
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups
}
}

View File

@@ -1,4 +1,4 @@
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import { computed, reactive, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
@@ -28,66 +28,73 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
)
})
let cancelled = false
onUnmounted(() => {
cancelled = true
})
watch(
() => toValue(cardSource),
async (card, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
onMounted(async () => {
const card = toValue(cardSource)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
for (const key of Object.keys(enrichedDetails)) {
delete enrichedDetails[key as unknown as number]
}
if (runtimeErrors.length === 0) return
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(() => systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
}
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (!systemStatsStore.systemStats || cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})()
if (!workflow) return
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
}
}
})
},
{ immediate: true }
)
return { displayedDetailsMap }
}

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

@@ -3528,6 +3528,23 @@
"missingModelsTitle": "Missing Models",
"assetLoadTimeout": "Model detection timed out. Try reloading the workflow.",
"downloadAll": "Download all"
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library",
"confirmSelection": "Confirm selection",
"cancelSelection": "Cancel selection",
"or": "OR"
}
},
"errorOverlay": {

View File

@@ -0,0 +1,61 @@
<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="MEDIA_TYPE_ICONS[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]'
}
</script>

View File

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

View File

@@ -0,0 +1,318 @@
<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>

View File

@@ -0,0 +1,224 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isCloud } from '@/platform/distribution/types'
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
return resolveComboValues(result.widget)
.filter((v) => v !== candidate.name)
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
const assetsStore = useAssetsStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveLibraryOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
useToastStore().addAlert(
st(
'toastMessages.unsupportedFileType',
'Unsupported file type. Please select a valid file.'
)
)
return
}
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
// Refresh assets store (non-critical — upload already succeeded)
try {
await assetsStore.updateInputs()
} catch {
// Asset list refresh failed but upload is valid; selection can proceed
}
} catch {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'
import {
verifyCloudMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
function makeCandidate(
nodeId: string,
name: string,
overrides: Partial<MissingMediaCandidate> = {}
): MissingMediaCandidate {
return {
nodeId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name,
isMissing: true,
...overrides
}
}
describe('groupCandidatesByName', () => {
it('groups candidates with the same name', () => {
const candidates = [
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'other.png')
]
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 = [
makeCandidate('1', 'sound.mp3', {
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio'
}),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
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 = [
makeCandidate('1', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('video')
})
it('groups multiple names within the same media type', () => {
const candidates = [
makeCandidate('1', 'a.png'),
makeCandidate('2', 'b.png'),
makeCandidate('3', 'a.png')
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image')
expect(result[0].items).toHaveLength(2)
expect(
result[0].items.find((i) => i.name === 'a.png')?.referencingNodes
).toHaveLength(2)
})
})
describe('verifyCloudMediaCandidates', () => {
it('marks candidates missing when not in input assets', async () => {
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined }),
makeCandidate('2', '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('calls updateInputs before checking assets', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(true)
})
it('respects abort signal before execution', async () => {
const controller = new AbortController()
controller.abort()
const candidates = [
makeCandidate('1', 'abc123.png', { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates, controller.signal)
expect(candidates[0].isMissing).toBeUndefined()
})
it('respects abort signal after updateInputs', async () => {
const controller = new AbortController()
const candidates = [makeCandidate('1', 'abc.png', { isMissing: undefined })]
const mockStore = {
updateInputs: async () => {
controller.abort()
},
inputAssets: [{ asset_hash: 'abc.png', name: 'photo.png' }]
}
await verifyCloudMediaCandidates(candidates, controller.signal, mockStore)
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(true)
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', 'abc.png', { isMissing: false })]
const mockStore = {
updateInputs: async () => {},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(candidates[0].isMissing).toBe(false)
})
it('skips entirely when no pending candidates', async () => {
let updateCalled = false
const candidates = [makeCandidate('1', 'abc.png', { isMissing: true })]
const mockStore = {
updateInputs: async () => {
updateCalled = true
},
inputAssets: []
}
await verifyCloudMediaCandidates(candidates, undefined, mockStore)
expect(updateCalled).toBe(false)
})
})

View File

@@ -0,0 +1,159 @@
import { groupBy } from 'es-toolkit'
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'
import { resolveComboValues } from '@/utils/litegraphUtil'
/** Map of node types to their media widget name and media type. */
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'
}
/**
* 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 = resolveComboValues(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 grouped = groupBy(candidates, (c) => c.mediaType)
const order: MediaType[] = ['image', 'video', 'audio']
return order
.filter((t) => t in grouped)
.map((mediaType) => ({
mediaType,
items: groupCandidatesByName(grouped[mediaType])
}))
}

View File

@@ -0,0 +1,197 @@
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 including interaction state', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
const controller = store.createVerificationAbortController()
store.clearMissingMedia()
expect(store.missingMediaCandidates).toBeNull()
expect(store.hasMissingMedia).toBe(false)
expect(store.missingMediaCount).toBe(0)
expect(controller.signal.aborted).toBe(true)
expect(store.expandState).toEqual({})
expect(store.uploadState).toEqual({})
expect(store.pendingSelection).toEqual({})
})
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('removeMissingMediaByWidget removes matching node+widget entry', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('clip.mp4')
})
it('removeMissingMediaByWidget nulls candidates when last entry removed', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toBeNull()
expect(store.hasMissingMedia).toBe(false)
})
it('removeMissingMediaByWidget ignores non-matching entries', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.removeMissingMediaByWidget('99', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removeMissingMediaByName clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByName('photo.png')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
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,154 @@
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(
() =>
new Set(missingMediaCandidates.value?.map((m) => String(m.nodeId)) ?? [])
)
/**
* 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
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
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 clearInteractionStateForName(name: string) {
delete expandState.value[name]
delete uploadState.value[name]
delete pendingSelection.value[name]
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
clearInteractionStateForName(name)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter(
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function clearMissingMedia() {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
missingMediaCandidates,
hasMissingMedia,
missingMediaCount,
missingMediaNodeIds,
missingMediaAncestorExecutionIds,
activeMissingMediaGraphIds,
setMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
clearMissingMedia,
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
}
})

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

@@ -22,6 +22,7 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { resolveComboValues } from '@/utils/litegraphUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
@@ -50,14 +51,6 @@ export function isModelFileName(name: string): boolean {
return Array.from(MODEL_FILE_EXTENSIONS).some((ext) => lower.endsWith(ext))
}
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 and asset widgets on configured graph nodes for model-like values.
* Must be called after `graph.configure()` so widget name/value mappings are accurate.
@@ -139,7 +132,7 @@ function scanComboWidget(
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboOptions(widget)
const options = resolveComboValues(widget)
const inOptions = options.includes(value)
return {

View File

@@ -11,8 +11,10 @@ import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')

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
)
await this.runMissingMediaPipeline()
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
}
@@ -1565,6 +1573,44 @@ export class ComfyApp {
return { missingModels }
}
private async runMissingMediaPipeline(): Promise<void> {
const missingMediaStore = useMissingMediaStore()
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
if (!candidates.length) return
if (isCloud) {
const controller = missingMediaStore.createVerificationAbortController()
void 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
)
useToastStore().add({
severity: 'warn',
summary: st(
'toastMessages.missingMediaVerificationFailed',
'Failed to verify missing media. Some inputs may not be shown in the Errors tab.'
),
life: 5000
})
})
} 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)
@@ -157,6 +160,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
options
)
missingModelStore.removeMissingModelByWidget(executionId, widgetName)
missingMediaStore.removeMissingMediaByWidget(executionId, widgetName)
}
/** Set missing models and open the error overlay if the Errors tab is enabled. */
@@ -170,6 +174,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 +212,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
hasPromptError.value ||
hasNodeError.value ||
missingNodesStore.hasMissingNodes ||
missingModelStore.hasMissingModels
missingModelStore.hasMissingModels ||
missingMediaStore.hasMissingMedia
)
const allErrorExecutionIds = computed<string[]>(() => {
@@ -233,7 +249,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 +343,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(lastNodeErrors, missingModelStore)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
@@ -360,6 +377,9 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Missing model coordination (delegates to missingModelStore)
surfaceMissingModels,
// Missing media coordination (delegates to missingMediaStore)
surfaceMissingMedia,
// Lookup helpers
getNodeErrors,
slotHasError,

View File

@@ -108,6 +108,14 @@ export function isAudioNode(node: LGraphNode | undefined): boolean {
return !!node && node.previewMediaType === 'audio'
}
export function resolveComboValues(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)
}
export function addToComboValues(widget: IComboWidget, value: string) {
if (!widget.options) widget.options = { values: [] }
if (!widget.options.values) widget.options.values = []

View File

@@ -0,0 +1,5 @@
/** Accepted MIME types for the image upload file picker. */
export const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
/** Accepted MIME types for the video upload file picker. */
export const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'