mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
68
browser_tests/assets/missing/missing_media_multiple.json
Normal file
68
browser_tests/assets/missing/missing_media_multiple.json
Normal 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
|
||||
}
|
||||
42
browser_tests/assets/missing/missing_media_single.json
Normal file
42
browser_tests/assets/missing/missing_media_single.json
Normal 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
|
||||
}
|
||||
BIN
browser_tests/assets/test_upload_image.png
Normal file
BIN
browser_tests/assets/test_upload_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
@@ -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'
|
||||
|
||||
225
browser_tests/tests/missingMedia.spec.ts
Normal file
225
browser_tests/tests/missingMedia.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
61
src/platform/missingMedia/components/MissingMediaCard.vue
Normal file
61
src/platform/missingMedia/components/MissingMediaCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
318
src/platform/missingMedia/components/MissingMediaRow.vue
Normal file
318
src/platform/missingMedia/components/MissingMediaRow.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
207
src/platform/missingMedia/missingMediaScan.test.ts
Normal file
207
src/platform/missingMedia/missingMediaScan.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
159
src/platform/missingMedia/missingMediaScan.ts
Normal file
159
src/platform/missingMedia/missingMediaScan.ts
Normal 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])
|
||||
}))
|
||||
}
|
||||
197
src/platform/missingMedia/missingMediaStore.test.ts
Normal file
197
src/platform/missingMedia/missingMediaStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
154
src/platform/missingMedia/missingMediaStore.ts
Normal file
154
src/platform/missingMedia/missingMediaStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
38
src/platform/missingMedia/types.ts
Normal file
38
src/platform/missingMedia/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'audio'
|
||||
|
||||
/**
|
||||
* A single (node, widget, media file) binding detected by the missing media pipeline.
|
||||
* The same file name may appear multiple times across different nodes.
|
||||
*/
|
||||
export interface MissingMediaCandidate {
|
||||
nodeId: NodeId
|
||||
nodeType: string
|
||||
widgetName: string
|
||||
mediaType: MediaType
|
||||
/** Display name (plain filename for OSS, asset hash for cloud). */
|
||||
name: string
|
||||
/**
|
||||
* - `true` — confirmed missing
|
||||
* - `false` — confirmed present
|
||||
* - `undefined` — pending async verification (cloud only)
|
||||
*/
|
||||
isMissing: boolean | undefined
|
||||
}
|
||||
|
||||
/** View model grouping multiple candidate references under a single file name. */
|
||||
export interface MissingMediaViewModel {
|
||||
name: string
|
||||
mediaType: MediaType
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** A group of missing media items sharing the same media type. */
|
||||
export interface MissingMediaGroup {
|
||||
mediaType: MediaType
|
||||
items: MissingMediaViewModel[]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
5
src/utils/mediaUploadUtil.ts
Normal file
5
src/utils/mediaUploadUtil.ts
Normal 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'
|
||||
Reference in New Issue
Block a user