Compare commits

...

5 Commits

Author SHA1 Message Date
jaeone94
593fb3bbc3 test: cover promoted missing media edge cases 2026-06-28 19:33:17 +09:00
jaeone94
0bf9d8c44b test: trim promoted missing media scaffolding 2026-06-28 19:10:05 +09:00
jaeone94
bce7e4c16a fix: align promoted missing media contracts 2026-06-28 18:55:58 +09:00
jaeone94
1ca5df71fc test: cover promoted missing media contracts 2026-06-28 18:51:05 +09:00
jaeone94
7ca3981a15 refactor: extract promoted widget error clearing helper 2026-06-28 18:20:03 +09:00
12 changed files with 653 additions and 97 deletions

View File

@@ -12,6 +12,7 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { clearWidgetRelatedErrorScopes } from '@/composables/graph/widgetErrorClearing'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
@@ -247,25 +248,20 @@ function clearWidgetErrors(
const executionId = getExecutionIdByNode(rootGraph, widgetNode)
if (!executionId) return
const options = { min: widget.options?.min, max: widget.options?.max }
const range = { min: widget.options?.min, max: widget.options?.max }
const source = resolvePromotedWidgetSource(rootGraph, widgetNode, widget)
if (source?.sourceExecutionId) {
executionErrorStore.clearWidgetRelatedErrors(
source.sourceExecutionId,
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
executionId,
widget.name,
widget.name,
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors: executionErrorStore.clearWidgetRelatedErrors,
host: { executionId, widgetName: widget.name },
source: source?.sourceExecutionId
? {
executionId: source.sourceExecutionId,
widgetName: source.sourceWidgetName
}
: undefined,
value,
options
)
range
})
}
function setWidgetValue(

View File

@@ -839,6 +839,58 @@ describe('realtime verification staleness guards', () => {
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
it('skips adding verified promoted media when source is bypassed before verification resolved', async () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const host = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = host.graph as LGraph
rootGraph.add(host)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const promotedCandidate: MissingMediaCandidate = {
nodeId: String(host.id),
sourceExecutionId: createNodeExecutionId([host.id, interiorNode.id]),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'promoted_image.png',
isMissing: undefined
}
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockImplementation(
(_rootGraph, node) => (node === host ? [promotedCandidate] : [])
)
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(rootGraph)
host.mode = LGraphEventMode.ALWAYS
rootGraph.onTrigger?.({
type: 'node:property:changed',
nodeId: host.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
interiorNode.mode = LGraphEventMode.BYPASS
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
it('skips adding verified model when rootGraph switched before verification resolved', async () => {
// Workflow A has a pending candidate on node id=1. A is replaced
// by workflow B (fresh LGraph, potentially has a node with the
@@ -1075,10 +1127,10 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('passes widgetName (not errorInputName) for model lookup', () => {
it('routes validation and missing-asset widget names separately', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
const widget = node.addWidget('number', 'steps', 42, () => undefined, {
const widget = node.addWidget('number', 'store_name', 42, () => undefined, {
min: 0,
max: 100
})
@@ -1089,12 +1141,12 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const clearSpy = vi.spyOn(store, 'clearWidgetRelatedErrors')
node.onWidgetChanged!.call(node, 'steps', 42, 0, widget)
node.onWidgetChanged!.call(node, 'display_name', 42, 0, widget)
expect(clearSpy).toHaveBeenCalledWith(
String(node.id),
'steps',
'steps',
'display_name',
'store_name',
42,
{ min: 0, max: 100 }
)

View File

@@ -6,6 +6,7 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { clearWidgetRelatedErrorScopes } from '@/composables/graph/widgetErrorClearing'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -93,25 +94,25 @@ function installNodeHooks(node: LGraphNode): void {
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const options = { min: widget.options?.min, max: widget.options?.max }
const executionErrorStore = useExecutionErrorStore()
const range = { min: widget.options?.min, max: widget.options?.max }
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (source?.sourceExecutionId) {
useExecutionErrorStore().clearWidgetRelatedErrors(
source.sourceExecutionId,
source.sourceWidgetName,
source.sourceWidgetName,
newValue,
options
)
}
useExecutionErrorStore().clearWidgetRelatedErrors(
hostExecId,
name,
widget.name,
newValue,
options
)
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors: executionErrorStore.clearWidgetRelatedErrors,
host: {
executionId: hostExecId,
errorInputName: name,
widgetName: widget.name
},
source: source?.sourceExecutionId
? {
executionId: source.sourceExecutionId,
widgetName: source.sourceWidgetName
}
: undefined,
value: newValue,
range
})
}
)
}
@@ -265,14 +266,6 @@ function isModelCandidateStillActive(
return isMissingCandidateActive(app.rootGraph, candidate)
}
function isNodeCandidateStillActive(nodeId: unknown): boolean {
return (
app.rootGraph != null &&
nodeId != null &&
isExecutionPathActive(app.rootGraph, String(nodeId))
)
}
async function verifyAndAddPendingModels(
pending: MissingModelCandidate[]
): Promise<void> {
@@ -300,7 +293,7 @@ async function verifyAndAddPendingMedia(
await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isNodeCandidateStillActive(c.nodeId)
(c) => c.isMissing === true && isMissingCandidateActive(app.rootGraph, c)
)
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
} catch (error: unknown) {
@@ -382,6 +375,7 @@ function removeNodeErrors(node: LGraphNode, execId: string): void {
modelStore.removeMissingModelsByNodeId(execId)
modelStore.removeMissingModelsBySourceScope(execId)
mediaStore.removeMissingMediaByNodeId(execId)
mediaStore.removeMissingMediaBySourceScope(execId)
nodesStore.removeMissingNodesByNodeId(execId)
// For subgraph containers, also remove errors from interior nodes.

View File

@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import { clearWidgetRelatedErrorScopes } from '@/composables/graph/widgetErrorClearing'
import { createNodeExecutionId } from '@/types/nodeIdentification'
describe('clearWidgetRelatedErrorScopes', () => {
it('clears only the host scope for normal widgets', () => {
const clearWidgetRelatedErrors = vi.fn()
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors,
host: {
executionId: createNodeExecutionId([65]),
widgetName: 'ckpt_name'
},
value: 'real_model.safetensors',
range: { min: 0, max: 10 }
})
expect(clearWidgetRelatedErrors).toHaveBeenCalledOnce()
expect(clearWidgetRelatedErrors).toHaveBeenCalledWith(
createNodeExecutionId([65]),
'ckpt_name',
'ckpt_name',
'real_model.safetensors',
{ min: 0, max: 10 }
)
})
it('preserves distinct validation and missing-asset widget names', () => {
const clearWidgetRelatedErrors = vi.fn()
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors,
host: {
executionId: createNodeExecutionId([65]),
errorInputName: 'display_name',
widgetName: 'store_name'
},
value: 'real_model.safetensors'
})
expect(clearWidgetRelatedErrors).toHaveBeenCalledWith(
createNodeExecutionId([65]),
'display_name',
'store_name',
'real_model.safetensors',
undefined
)
})
it('clears promoted source scope before host scope', () => {
const clearWidgetRelatedErrors = vi.fn()
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors,
source: {
executionId: createNodeExecutionId([65, 42]),
widgetName: 'ckpt_name'
},
host: {
executionId: createNodeExecutionId([65]),
widgetName: 'promoted_ckpt'
},
value: 'real_model.safetensors',
range: { min: undefined, max: undefined }
})
expect(clearWidgetRelatedErrors).toHaveBeenNthCalledWith(
1,
createNodeExecutionId([65, 42]),
'ckpt_name',
'ckpt_name',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(clearWidgetRelatedErrors).toHaveBeenNthCalledWith(
2,
createNodeExecutionId([65]),
'promoted_ckpt',
'promoted_ckpt',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
})
})

View File

@@ -0,0 +1,63 @@
import type { NodeExecutionId } from '@/types/nodeIdentification'
interface WidgetErrorRangeOptions {
min?: number
max?: number
}
interface WidgetErrorScope {
executionId: NodeExecutionId
/** Validation error key matched against `error.extra_info.input_name`. */
errorInputName?: string
/** Missing model/media store lookup key. */
widgetName: string
}
type ClearWidgetRelatedErrors = (
executionId: NodeExecutionId,
errorInputName: string,
widgetName: string,
newValue: unknown,
range?: WidgetErrorRangeOptions
) => void
interface ClearWidgetRelatedErrorScopesOptions {
clearWidgetRelatedErrors: ClearWidgetRelatedErrors
host: WidgetErrorScope
source?: WidgetErrorScope
value: unknown
range?: WidgetErrorRangeOptions
}
/**
* Clear the interior promoted-widget source first, then the host widget.
* The range belongs to the widget surface that emitted the value.
*/
export function clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors,
host,
source,
value,
range
}: ClearWidgetRelatedErrorScopesOptions): void {
if (source) {
applyScopeClear(clearWidgetRelatedErrors, source, value, range)
}
applyScopeClear(clearWidgetRelatedErrors, host, value, range)
}
function applyScopeClear(
clearWidgetRelatedErrors: ClearWidgetRelatedErrors,
scope: WidgetErrorScope,
value: unknown,
range?: WidgetErrorRangeOptions
): void {
clearWidgetRelatedErrors(
scope.executionId,
scope.errorInputName ?? scope.widgetName,
scope.widgetName,
value,
range
)
}

View File

@@ -1,13 +1,22 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
import type * as FetchJobsModule from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { MissingMediaAssetResolver } from './missingMediaAssetResolver'
import {
scanAllMediaCandidates,
@@ -32,13 +41,43 @@ const { mockFetchHistoryPage } = vi.hoisted(() => ({
mockFetchHistoryPage: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
vi.mock('@/utils/graphTraversalUtil', () => {
type TestNode = LGraphNode & {
_testExecutionId?: string
}
type TestGraph = { _testNodes: TestNode[] }
const findNodeByExecutionId = (graph: TestGraph, executionId: string) =>
graph._testNodes.find(
(node) => (node._testExecutionId ?? String(node.id)) === executionId
)
const isInactive = (node: LGraphNode | undefined) =>
node?.mode === 2 || node?.mode === 4
const isAncestorPathActive = (graph: TestGraph, executionId: string) => {
const path = executionId.split(':')
const prefixes = path
.slice(0, -1)
.map((_, index) => path.slice(0, index + 1).join(':'))
return prefixes.every((prefix) => {
const node = findNodeByExecutionId(graph, prefix)
return !isInactive(node)
})
}
return {
collectAllNodes: (graph: TestGraph) => graph._testNodes,
getExecutionIdByNode: (_graph: unknown, node: TestNode) =>
node._testExecutionId ?? String(node.id),
isExecutionPathActive: (graph: TestGraph, executionId: string) => {
const node = findNodeByExecutionId(graph, executionId)
return (
!!node && !isInactive(node) && isAncestorPathActive(graph, executionId)
)
}
}
})
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
@@ -115,6 +154,106 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeNestedPromotedMediaGraph({
hostValue = 'missing.png',
innerMode = 0,
sourceOptions = ['existing.png'],
sourceValue = 'stale.png'
}: {
hostValue?: string
innerMode?: number
sourceOptions?: string[]
sourceValue?: string
} = {}): LGraph {
const outerLinkId = 12
const innerLinkId = 13
const sourceWidget = makeMediaCombo('image', sourceValue, sourceOptions)
const sourceInput = fromAny<INodeInputSlot, unknown>({
name: 'image',
link: innerLinkId
})
const leafNode = makeMediaNode(42, 'LoadImage', [sourceWidget], 0, '65:77:42')
Object.assign(leafNode, {
inputs: [sourceInput],
isSubgraphNode: () => false,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.name === sourceWidget.name ? sourceInput : undefined,
getWidgetFromSlot: (input: INodeInputSlot | undefined) =>
input === sourceInput ? sourceWidget : undefined
})
const innerWidgetId = widgetId('graph', toNodeId(77), 'inner_image')
const innerInput = fromAny<INodeInputSlot, unknown>({
name: 'inner_image',
link: outerLinkId,
widget: { name: 'inner_image' },
widgetId: innerWidgetId
})
const innerNode = fromAny<LGraphNode, unknown>({
id: toNodeId(77),
type: 'inner-subgraph-uuid',
mode: innerMode,
inputs: [innerInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === innerInput.widgetId ||
widget?.name === innerInput.name
? innerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'inner_image', linkIds: [innerLinkId] }]
},
getLink: (id: number) =>
id === innerLinkId
? { resolve: () => ({ inputNode: leafNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(leafNode.id) ? leafNode : null
},
_testExecutionId: '65:77'
})
const outerWidgetId = widgetId('graph', toNodeId(65), 'outer_image')
useWidgetValueStore().registerWidget(outerWidgetId, {
type: 'combo',
value: hostValue,
options: {},
label: 'Outer image'
})
const outerInput = fromAny<INodeInputSlot, unknown>({
name: 'outer_image',
link: null,
widget: { name: 'outer_image' },
widgetId: outerWidgetId
})
const outerNode = fromAny<LGraphNode, unknown>({
id: toNodeId(65),
type: 'outer-subgraph-uuid',
inputs: [outerInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === outerInput.widgetId ||
widget?.name === outerInput.name
? outerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'outer_image', linkIds: [outerLinkId] }]
},
getLink: (id: number) =>
id === outerLinkId
? { resolve: () => ({ inputNode: innerNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(innerNode.id) ? innerNode : null
},
_testExecutionId: '65'
})
return makeGraph([outerNode, innerNode, leafNode])
}
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
@@ -208,6 +347,25 @@ describe('scanNodeMediaCandidates', () => {
expect(result).toEqual([])
})
it('skips plain media widgets backed by linked inputs', () => {
const graph = makeGraph([])
const widget = makeMediaCombo('image', 'photo.png', [])
const linkedInput = fromAny<INodeInputSlot, unknown>({
name: 'image',
link: 12,
widget: { name: 'image' }
})
const node = makeMediaNode(1, 'LoadImage', [widget], 0)
Object.assign(node, {
getSlotFromWidget: (candidate: IBaseWidget | undefined) =>
candidate === widget ? linkedInput : undefined
})
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it.for([false, true])(
'returns empty while a media upload is pending on the node (isCloud: %s)',
(isCloud) => {
@@ -410,6 +568,50 @@ describe('scanAllMediaCandidates', () => {
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
})
it('scans nested promoted media widgets through the outer host identity', () => {
const result = scanAllMediaCandidates(makeNestedPromotedMediaGraph(), false)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeId: '65',
sourceExecutionId: '65:77:42',
nodeType: 'LoadImage',
widgetName: 'outer_image',
mediaType: 'image',
name: 'missing.png',
isMissing: true
})
})
it('uses the host value with source options for resolved promoted media', () => {
const result = scanAllMediaCandidates(
makeNestedPromotedMediaGraph({
hostValue: 'existing.png',
sourceOptions: ['existing.png'],
sourceValue: 'missing.png'
}),
false
)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeId: '65',
sourceExecutionId: '65:77:42',
widgetName: 'outer_image',
name: 'existing.png',
isMissing: false
})
})
it('skips promoted media when an intermediate subgraph is bypassed', () => {
const result = scanAllMediaCandidates(
makeNestedPromotedMediaGraph({ innerMode: 4 }),
false
)
expect(result).toHaveLength(0)
})
})
describe('groupCandidatesByName', () => {

View File

@@ -11,14 +11,21 @@ import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import {
inputForWidget,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
collectAllNodes,
getExecutionIdByNode
getExecutionIdByNode,
isExecutionPathActive
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isAbortError } from '@/utils/typeGuardUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
getAnnotatedMediaPathTypeForDetection,
getMediaPathDetectionNames,
@@ -45,6 +52,21 @@ function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
function isInactiveMode(mode: number | undefined): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
interface MediaWidgetScanTarget {
executionId: NodeExecutionId
nodeType: string
candidateWidgetName: string
definitionWidgetName: string
sourceExecutionId?: NodeExecutionId
valueWidget: IBaseWidget
definitionWidget: IBaseWidget
isUploading?: boolean
}
/**
* Scan combo widgets on media nodes for file values that may be missing.
*
@@ -62,13 +84,7 @@ export function scanAllMediaCandidates(
const candidates: MissingMediaCandidate[] = []
for (const node of allNodes) {
if (!node.widgets?.length) continue
if (node.isSubgraphNode?.()) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
if (isInactiveMode(node.mode)) continue
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
}
@@ -82,21 +98,31 @@ export function scanNodeMediaCandidates(
node: LGraphNode,
isCloud: boolean
): MissingMediaCandidate[] {
if (!node.widgets?.length) return []
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
if (!mediaInfo) return []
if (node.isUploading) return []
const widgets = node.isSubgraphNode?.()
? promotedInputWidgets(node)
: (node.widgets ?? [])
if (!widgets.length) return []
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) return []
const candidates: MissingMediaCandidate[] = []
for (const widget of node.widgets) {
if (!isComboWidget(widget)) continue
if (widget.name !== mediaInfo.widgetName) continue
for (const widget of widgets) {
const target = getMediaWidgetScanTarget(
rootGraph,
node,
widget,
executionId
)
if (!target) continue
const value = widget.value
const mediaInfo = MEDIA_NODE_WIDGETS[target.nodeType]
if (!mediaInfo) continue
if (target.isUploading) continue
if (!isComboWidget(target.definitionWidget)) continue
if (target.definitionWidgetName !== mediaInfo.widgetName) continue
const value = target.valueWidget.value
if (typeof value !== 'string' || !value.trim()) continue
let isMissing: boolean | undefined
@@ -107,7 +133,7 @@ export function scanNodeMediaCandidates(
if (type === 'output') {
isMissing = undefined
} else {
const options = resolveComboValues(widget)
const options = resolveComboValues(target.definitionWidget)
const detectionNames = getMediaPathDetectionNames(value)
const existsInOptions = detectionNames.some((name) =>
options.includes(name)
@@ -117,9 +143,12 @@ export function scanNodeMediaCandidates(
}
candidates.push({
nodeId: executionId,
nodeType: node.type,
widgetName: widget.name,
nodeId: target.executionId,
...(target.sourceExecutionId && {
sourceExecutionId: target.sourceExecutionId
}),
nodeType: target.nodeType,
widgetName: target.candidateWidgetName,
mediaType: mediaInfo.mediaType,
name: value,
isMissing
@@ -129,6 +158,51 @@ export function scanNodeMediaCandidates(
return candidates
}
function getMediaWidgetScanTarget(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
executionId: NodeExecutionId
): MediaWidgetScanTarget | null {
const input = getInputForWidget(node, widget)
if (input?.link != null) return null
if (!node.isSubgraphNode?.()) {
return {
executionId,
nodeType: node.type,
candidateWidgetName: widget.name,
definitionWidgetName: widget.name,
valueWidget: widget,
definitionWidget: widget,
isUploading: node.isUploading
}
}
if (!input) return null
const source = resolvePromotedWidgetSource(rootGraph, node, widget)
const sourceExecutionId = source?.sourceExecutionId
if (!sourceExecutionId) return null
if (!isExecutionPathActive(rootGraph, sourceExecutionId)) return null
return {
executionId,
nodeType: source.sourceNode.type,
candidateWidgetName: widget.name,
definitionWidgetName: source.sourceWidgetName,
sourceExecutionId,
valueWidget: widget,
definitionWidget: source.sourceWidget,
isUploading: source.sourceNode.isUploading
}
}
function getInputForWidget(node: LGraphNode, widget: IBaseWidget) {
if (typeof node.getSlotFromWidget !== 'function') return undefined
return inputForWidget(node, widget)
}
interface MediaVerificationOptions {
isCloud: boolean
signal?: AbortSignal

View File

@@ -1,6 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { useMissingMediaStore } from './missingMediaStore'
import type { MissingMediaCandidate } from './types'
@@ -307,4 +309,72 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
})
describe('removeMissingMediaBySourceScope', () => {
it('removes host-keyed candidates whose source path is in the scope', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
{
...makeCandidate('65', 'a.png'),
sourceExecutionId: createNodeExecutionId([65, 77, 42])
},
{
...makeCandidate('80', 'b.png'),
sourceExecutionId: createNodeExecutionId([80, 77, 42])
}
])
store.removeMissingMediaBySourceScope('65:77')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('b.png')
})
it('does not remove candidates by host nodeId alone', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('65', 'a.png')])
store.removeMissingMediaBySourceScope('65')
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removes candidates whose source path exactly matches the scope', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
{
...makeCandidate('65', 'a.png'),
sourceExecutionId: createNodeExecutionId([65, 77])
},
{
...makeCandidate('80', 'b.png'),
sourceExecutionId: createNodeExecutionId([80, 77])
}
])
store.removeMissingMediaBySourceScope('65:77')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('b.png')
})
it('does not match sibling source paths sharing a numeric prefix', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
{
...makeCandidate('6', 'a.png'),
sourceExecutionId: createNodeExecutionId([6, 77, 42])
},
{
...makeCandidate('60', 'b.png'),
sourceExecutionId: createNodeExecutionId([60, 77, 42])
}
])
store.removeMissingMediaBySourceScope('6')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('b.png')
})
})
})

View File

@@ -117,6 +117,25 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
missingMediaCandidates.value = remaining.length ? remaining : null
}
function removeMissingMediaBySourceScope(executionId: string) {
if (!missingMediaCandidates.value) return
const prefix = `${executionId}:`
const remaining = missingMediaCandidates.value.filter((candidate) => {
const sourceExecutionId =
candidate.sourceExecutionId == null
? undefined
: String(candidate.sourceExecutionId)
// Host-keyed promoted candidates are removed by their interior source
// path. The trailing colon prevents sibling prefix matches ("6" vs "60").
const inScope =
sourceExecutionId === executionId ||
sourceExecutionId?.startsWith(prefix) === true
return !inScope
})
if (remaining.length === missingMediaCandidates.value.length) return
missingMediaCandidates.value = remaining.length ? remaining : null
}
function addMissingMedia(media: MissingMediaCandidate[]) {
if (!media.length) return
const existing = missingMediaCandidates.value ?? []
@@ -150,6 +169,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
removeMissingMediaBySourceScope,
clearMissingMedia,
createVerificationAbortController,

View File

@@ -1,3 +1,4 @@
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { SerializedNodeId } from '@/types/nodeId'
export type MediaType = 'image' | 'video' | 'audio'
@@ -8,6 +9,7 @@ export type MediaType = 'image' | 'video' | 'audio'
*/
export interface MissingMediaCandidate {
nodeId: SerializedNodeId
sourceExecutionId?: NodeExecutionId
nodeType: string
widgetName: string
mediaType: MediaType

View File

@@ -7,6 +7,7 @@ import type {
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { clearWidgetRelatedErrorScopes } from '@/composables/graph/widgetErrorClearing'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
@@ -97,24 +98,19 @@ function createWidgetUpdateHandler(
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
const range = { min: widgetOptions?.min, max: widgetOptions?.max }
clearWidgetRelatedErrorScopes({
clearWidgetRelatedErrors: executionErrorStore.clearWidgetRelatedErrors,
host: { executionId: nodeExecId, widgetName: widget.name },
source: widget.sourceExecutionId
? {
executionId: widget.sourceExecutionId,
widgetName: widget.sourceWidgetName ?? widget.name
}
: undefined,
value: newValue,
range
})
}
}

View File

@@ -118,6 +118,7 @@ import {
forEachNode,
getNodeByExecutionId,
isAncestorPathActive,
isCandidateScopeActive,
isMissingCandidateActive,
triggerCallbackOnAllNodes
} from '@/utils/graphTraversalUtil'
@@ -1555,7 +1556,7 @@ export class ComfyApp {
const allCandidates = scanAllMediaCandidates(this.rootGraph, isCloud)
// Drop candidates whose enclosing subgraph is muted/bypassed.
const candidates = allCandidates.filter((c) =>
isAncestorPathActive(this.rootGraph, String(c.nodeId))
isCandidateScopeActive(this.rootGraph, c)
)
if (!candidates.length) {