mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Add detection and resolution UI for missing image/video/audio inputs (LoadImage, LoadVideo, LoadAudio nodes) in the Errors tab, mirroring the existing missing model pipeline. ## Changes - **What**: New `src/platform/missingMedia/` module — scan pipeline detects missing media files on workflow load (sync for OSS, async for cloud), surfaces them in the error tab with upload dropzone, thumbnail library select, and 2-step confirm flow - **Detection**: `scanAllMediaCandidates()` checks combo widget values against options; cloud path defers to `verifyCloudMediaCandidates()` via `assetsStore.updateInputs()` - **UI**: `MissingMediaCard` groups by media type; `MissingMediaRow` shows node name (single) or filename+count (multiple), upload dropzone with drag & drop, `MissingMediaLibrarySelect` with image/video thumbnails - **Resolution**: Upload via `/upload/image` API or select from library → status card → checkmark confirm → widget value applied, item removed from error list - **Integration**: `executionErrorStore` aggregates into `hasAnyError`/`totalErrorCount`; `useNodeErrorFlagSync` flags nodes on canvas; `useErrorGroups` renders in error tab - **Shared**: Extract `ACCEPTED_IMAGE_TYPES`/`ACCEPTED_VIDEO_TYPES` to `src/utils/mediaUploadUtil.ts`; extract `resolveComboValues` to `src/utils/litegraphUtil.ts` (shared across missingMedia + missingModel scan) - **Reverse clearing**: Widget value changes on nodes auto-remove corresponding missing media errors (via `clearWidgetRelatedErrors`) ## Testing ### Unit tests (22 tests) - `missingMediaScan.test.ts` (12): groupCandidatesByName, groupCandidatesByMediaType (ordering, multi-name), verifyCloudMediaCandidates (missing/present, abort before/after updateInputs, already resolved true/false, no-pending skip, updateInputs spy) - `missingMediaStore.test.ts` (10): setMissingMedia, clearMissingMedia (full lifecycle with interaction state), missingMediaNodeIds, hasMissingMediaOnNode, removeMissingMediaByWidget (match/no-match/last-entry), createVerificationAbortController ### E2E tests (10 scenarios in `missingMedia.spec.ts`) - Detection: error overlay shown, Missing Inputs group in errors tab, correct row count, dropzone + library select visibility, no false positive for valid media - Upload flow: file picker → uploading status card → confirm → row removed - Library select: dropdown → selected status card → confirm → row removed - Cancel: pending selection → returns to upload/library UI - All resolved: Missing Inputs group disappears - Locate node: canvas pans to missing media node ## Review Focus - Cloud verification path: `verifyCloudMediaCandidates` compares widget value against `asset_hash` — implicit contract - 2-step confirm mirrors missing model pattern (`pendingSelection` → confirm/cancel) - Event propagation guard on dropzone (`@drop.prevent.stop`) to prevent canvas LoadImage node creation - `clearAllErrors()` intentionally does NOT clear missing media (same as missing models — preserves pending repairs) - `runMissingMediaPipeline` is now `async` and `await`-ed, matching model pipeline ## Test plan - [x] OSS: load workflow with LoadImage referencing non-existent file → error tab shows it - [x] Upload file via dropzone → status card shows "Uploaded" → confirm → widget updated, error removed - [x] Select from library with thumbnail preview → confirm → widget updated, error removed - [x] Cancel pending selection → returns to upload/library UI - [x] Load workflow with valid images → no false positives - [x] Click locate-node → canvas navigates to the node - [x] Multiple nodes referencing different missing files → correct row count - [x] Widget value change on node → missing media error auto-removed ## Screenshots https://github.com/user-attachments/assets/631c0cb0-9706-4db2-8615-f24a4c3fe27d
198 lines
6.1 KiB
TypeScript
198 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
})
|