mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
5 Commits
chore/code
...
jaeone/pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
593fb3bbc3 | ||
|
|
0bf9d8c44b | ||
|
|
bce7e4c16a | ||
|
|
1ca5df71fc | ||
|
|
7ca3981a15 |
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
86
src/composables/graph/widgetErrorClearing.test.ts
Normal file
86
src/composables/graph/widgetErrorClearing.test.ts
Normal 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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
63
src/composables/graph/widgetErrorClearing.ts
Normal file
63
src/composables/graph/widgetErrorClearing.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user