refactor(subgraph): remove promotion store use

Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-07 22:36:01 -07:00
parent 6e25648373
commit 546c2cb4d4
26 changed files with 676 additions and 925 deletions

View File

@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -70,8 +70,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -83,13 +81,13 @@ function isWidgetShownOnParents(
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})

View File

@@ -14,13 +14,16 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -33,7 +36,6 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -55,9 +57,32 @@ const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName &&
a.disambiguatingSourceNodeId === b.disambiguatingSourceNodeId
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
@@ -81,37 +106,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
const entries = promotionEntries.value
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
}
}
return result
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {
@@ -126,7 +121,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
@@ -190,12 +185,19 @@ function setDraggableState() {
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
const subgraphInputs = node.subgraph.inputs
const hostInputs = node.inputs
if (
oldPosition >= subgraphInputs.length ||
newPosition >= subgraphInputs.length
)
return
const [input] = subgraphInputs.splice(oldPosition, 1)
if (input) subgraphInputs.splice(newPosition, 0, input)
const [nodeInput] = hostInputs.splice(oldPosition, 1)
if (nodeInput) hostInputs.splice(newPosition, 0, nodeInput)
canvasStore.canvas?.setDirty(true, true)
}
}

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -43,13 +41,17 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
return isLinkedPromotion(
node,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -82,16 +84,19 @@ function handleHideInput() {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -22,24 +21,18 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
return promotionStore.getPromotions(node.rootGraph.id, node.id)
})
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
@@ -51,34 +44,26 @@ const activeWidgets = computed<WidgetItem[]>({
const node = activeNode.value
if (!node) return []
return promotionEntries.value.flatMap(
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
)
const linkedWidgets = node.widgets.flatMap((widget): WidgetItem[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [[sourceNode, widget]]
})
const hostLocator = createNodeLocatorId(node.rootGraph.id, node.id)
const previewWidgets = previewExposureStore
.getExposures(node.rootGraph.id, hostLocator)
.flatMap((exposure): WidgetItem[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const widget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
)
return widget ? [[sourceNode, widget]] : []
})
return [...linkedWidgets, ...previewWidgets]
},
set(value: WidgetItem[]) {
const node = activeNode.value
@@ -86,17 +71,15 @@ const activeWidgets = computed<WidgetItem[]>({
console.error('Attempted to toggle widgets with no node selected')
return
}
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
const currentItems = activeWidgets.value
const currentKeys = new Set(currentItems.map(toKey))
const nextKeys = new Set(value.map(toKey))
for (const item of value) {
if (!currentKeys.has(toKey(item))) promote(item)
}
for (const item of currentItems) {
if (!nextKeys.has(toKey(item))) demote(item)
}
refreshPromotedWidgetRendering()
}
})
@@ -119,14 +102,8 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
(item: WidgetItem) =>
!activeWidgets.value.some((active) => toKey(active) === toKey(item))
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {

View File

@@ -9,7 +9,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { usePromotedPreviews } from './usePromotedPreviews'
@@ -83,6 +83,18 @@ function seedPreviewImages(
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = '$$canvas-image-preview'
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
createNodeLocatorId(setup.subgraphNode.rootGraph.id, setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
@@ -109,11 +121,6 @@ describe(usePromotedPreviews, () => {
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -122,11 +129,7 @@ describe(usePromotedPreviews, () => {
it('returns image preview for promoted $$ widget with outputs', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
@@ -146,11 +149,7 @@ describe(usePromotedPreviews, () => {
it('returns video type when interior node has video previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
@@ -162,11 +161,7 @@ describe(usePromotedPreviews, () => {
it('returns audio type when interior node has audio previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
@@ -185,16 +180,8 @@ describe(usePromotedPreviews, () => {
id: 20,
previewMediaType: 'image'
})
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
exposePreview(setup, '20')
seedOutputs(setup.subgraph.id, [10, 20])
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
@@ -212,11 +199,7 @@ describe(usePromotedPreviews, () => {
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
@@ -236,11 +219,7 @@ describe(usePromotedPreviews, () => {
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -262,11 +241,7 @@ describe(usePromotedPreviews, () => {
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -274,29 +249,16 @@ describe(usePromotedPreviews, () => {
it('skips missing interior nodes', () => {
const setup = createSetup()
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '99')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('ignores non-$$ promoted widgets', () => {
it('uses preview exposures by source preview name', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])

View File

@@ -5,7 +5,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
@@ -20,17 +19,13 @@ interface PromotedPreview {
/**
* Returns reactive preview media exposed by a host SubgraphNode.
*
* Reads first from the host-scoped {@link usePreviewExposureStore} (the
* canonical post-ADR-0009 source). Falls back to the legacy promotion-store
* `$$`-prefixed entries when no exposures have been migrated yet — the
* configure-time flush will normalize both into the exposure store on next
* load.
* Reads from the host-scoped {@link usePreviewExposureStore}, the canonical
* post-ADR-0009 source for display-only preview promotion.
*/
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const previewExposureStore = usePreviewExposureStore()
const promotionStore = usePromotionStore()
const nodeOutputStore = useNodeOutputStore()
const promotedPreviews = computed((): PromotedPreview[] => {
@@ -45,18 +40,10 @@ export function usePromotedPreviews(
hostLocator
)
const exposurePairs = exposures.length
? exposures.map((exposure) => ({
sourceNodeId: exposure.sourceNodeId,
sourceWidgetName: exposure.sourcePreviewName
}))
: promotionStore
.getPromotions(rootGraphId, node.id)
.filter((entry) => entry.sourceWidgetName.startsWith('$$'))
.map((entry) => ({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName
}))
const exposurePairs = exposures.map((exposure) => ({
sourceNodeId: exposure.sourceNodeId,
sourceWidgetName: exposure.sourcePreviewName
}))
if (!exposurePairs.length) return []

View File

@@ -39,9 +39,14 @@ function buildHost(): SubgraphNode {
function makeSource(
sourceNodeId: string,
sourceWidgetName: string
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
return { sourceNodeId, sourceWidgetName }
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
describe(classifyProxyEntry, () => {
@@ -73,6 +78,42 @@ describe(classifyProxyEntry, () => {
subgraphInputName: 'seed_link'
})
})
it('matches already-linked inputs by disambiguatingSourceNodeId when provided', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const firstInput = host.addInput('first_seed', '*')
firstInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'first'
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second'
})
const normalized = makeSource(String(innerNode.id), 'seed', 'second')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result.plan).toEqual({
kind: 'alreadyLinked',
subgraphInputName: 'second_seed'
})
})
})
describe('quarantine branches', () => {

View File

@@ -35,7 +35,10 @@ function findLinkedSubgraphInputName(
if (!widget || !isPromotedWidgetView(widget)) continue
if (
widget.sourceNodeId === normalized.sourceNodeId &&
widget.sourceWidgetName === normalized.sourceWidgetName
widget.sourceWidgetName === normalized.sourceWidgetName &&
(!normalized.disambiguatingSourceNodeId ||
widget.disambiguatingSourceNodeId ===
normalized.disambiguatingSourceNodeId)
) {
return input.name
}

View File

@@ -108,6 +108,34 @@ describe(migratePreviewExposure, () => {
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(2)
})
it('reuses an existing exposure for the same source preview', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const store = usePreviewExposureStore()
const locator = createNodeLocatorId(host.rootGraph.id, host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
const result = migratePreviewExposure({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
}),
store
})
expect(result).toEqual({
ok: true,
previewName: '$$canvas-image-preview'
})
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
it('returns missingSourceNode when the source node is absent', () => {
const host = buildHost()
const store = usePreviewExposureStore()

View File

@@ -54,6 +54,15 @@ export function migratePreviewExposure(
hostNode.rootGraph.id,
hostNode.id
)
const existing = store
.getExposures(hostNode.rootGraph.id, hostNodeLocator)
.find(
(exposure) =>
exposure.sourceNodeId === entry.normalized.sourceNodeId &&
exposure.sourcePreviewName === plan.sourcePreviewName
)
if (existing) return { ok: true, previewName: existing.name }
const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, {
sourceNodeId: entry.normalized.sourceNodeId,
sourcePreviewName: plan.sourcePreviewName

View File

@@ -39,13 +39,17 @@ function buildHost(): SubgraphNode {
function buildEntry(args: {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
plan: PendingMigrationEntry['plan']
hostValue?: PendingMigrationEntry['hostValue']
}): PendingMigrationEntry {
return {
normalized: {
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName
sourceWidgetName: args.sourceWidgetName,
...(args.disambiguatingSourceNodeId && {
disambiguatingSourceNodeId: args.disambiguatingSourceNodeId
})
},
legacyOrderIndex: 0,
hostValue: args.hostValue ?? HOST_VALUE_HOLE,
@@ -113,6 +117,47 @@ describe(repairValueWidget, () => {
expect(inputSlot._widget?.value).toBe(7)
})
it('applies host value to the linked input with the matching disambiguator', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const firstInput = host.addInput('first_seed', '*')
firstInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'first',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second',
value: 2
})
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second',
plan: { kind: 'alreadyLinked', subgraphInputName: 'second_seed' },
hostValue: 99
})
})
expect(result).toEqual({ ok: true, subgraphInputName: 'second_seed' })
expect(firstInput._widget?.value).toBe(1)
expect(secondInput._widget?.value).toBe(99)
})
it('returns missingSubgraphInput when the linked SubgraphInput is gone', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')

View File

@@ -24,14 +24,17 @@ interface RepairValueWidgetArgs {
function findHostInputForLinkedSource(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
) {
return hostNode.inputs.find((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
return (
widget.sourceNodeId === sourceNodeId &&
widget.sourceWidgetName === sourceWidgetName
widget.sourceWidgetName === sourceWidgetName &&
(!disambiguatingSourceNodeId ||
widget.disambiguatingSourceNodeId === disambiguatingSourceNodeId)
)
})
}
@@ -51,7 +54,8 @@ function repairAlreadyLinked(
const hostInput = findHostInputForLinkedSource(
hostNode,
entry.normalized.sourceNodeId,
entry.normalized.sourceWidgetName
entry.normalized.sourceWidgetName,
entry.normalized.disambiguatingSourceNodeId
)
if (!hostInput?._widget) {
return { ok: false, reason: 'missingSubgraphInput' }

View File

@@ -9,7 +9,13 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -22,6 +28,7 @@ import {
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected
} from './promotionUtils'
@@ -112,58 +119,67 @@ describe('pruneDisconnected', () => {
vi.restoreAllMocks()
})
it('removes disconnected entries and emits a dev warning', () => {
it('removes disconnected linked inputs and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const keptInput = interiorNode.addInput('kept', 'STRING')
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
keptInput.widget = { name: keptWidget.name }
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
it('does not prune preview exposures for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
const hostLocator = createNodeLocatorId(
subgraphNode.rootGraph.id,
subgraphNode.id
)
usePreviewExposureStore().addExposure(
subgraphNode.rootGraph.id,
hostLocator,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
)
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -232,6 +248,53 @@ describe('promoteRecommendedWidgets', () => {
updatePreviewsMock.mockReset()
})
it('promotes recommended value widgets through linked subgraph inputs', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Sampler')
const input = interiorNode.addInput('seed', 'INT')
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
expect(linkedInput).toBeDefined()
expect(input.link).not.toBeNull()
expect(linkedInput?.linkIds).toContain(input.link)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const hostLocator = createNodeLocatorId(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
expect(subgraph.inputs).toHaveLength(0)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -252,7 +315,7 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
@@ -261,17 +324,24 @@ describe('promoteRecommendedWidgets', () => {
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
const hostLocator = createNodeLocatorId(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
it('hydrates $$canvas-image-preview exposure on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
@@ -284,13 +354,20 @@ describe('promoteRecommendedWidgets', () => {
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
const hostLocator = createNodeLocatorId(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
})
})
@@ -314,12 +391,11 @@ describe('hasUnpromotedWidgets', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
const input = interiorNode.addInput('seed', 'STRING')
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
input.widget = { name: widget.name }
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})

View File

@@ -8,6 +8,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -15,12 +16,13 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
@@ -35,7 +37,8 @@ export function getWidgetName(w: IBaseWidget): string {
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): boolean {
return subgraphNode.inputs.some((input) => {
const w = input._widget
@@ -43,7 +46,9 @@ export function isLinkedPromotion(
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
w.sourceWidgetName === sourceWidgetName &&
(!disambiguatingSourceNodeId ||
w.disambiguatingSourceNodeId === disambiguatingSourceNodeId)
)
})
}
@@ -53,6 +58,69 @@ export function getSourceNodeId(w: IBaseWidget): string | undefined {
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function isPreviewExposed(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
const hostLocator = createNodeLocatorId(
subgraphNode.rootGraph.id,
subgraphNode.id
)
return usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === source.sourceNodeId &&
exposure.sourcePreviewName === source.sourceWidgetName
)
}
function isPromotedOnParent(
subgraphNode: SubgraphNode,
widget: IBaseWidget,
source: PromotedWidgetSource
): boolean {
if (isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName,
source.disambiguatingSourceNodeId
) || isPreviewExposed(subgraphNode, source)
)
}
export function isWidgetPromotedByAnySubgraphNode(
node: PartialNode,
widgetName: string
): boolean {
if (!('graph' in node)) return false
const graph = (node as LGraphNode).graph
if (!graph) return false
return graph.rootGraph.nodes.some(
(candidate) =>
candidate.isSubgraphNode() &&
isWidgetPromotedOnSubgraphNode(candidate, {
sourceNodeId: String(node.id),
sourceWidgetName: widgetName
})
)
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
@@ -69,11 +137,68 @@ function toPromotionSource(
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
parent.setDirtyCanvas?.(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
type CanonicalPromotionResult =
| { ok: true }
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
export function promoteValueWidgetViaSubgraphInput(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
return { ok: true }
}
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
const inputName = nextUniqueName(sourceWidgetName, existingNames)
const subgraphInput = subgraphNode.subgraph.addInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
return { ok: true }
}
export function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourcePreviewName: string
): void {
const store = usePreviewExposureStore()
const rootGraphId = subgraphNode.rootGraph.id
const hostLocator = createNodeLocatorId(rootGraphId, subgraphNode.id)
const existing = store
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourcePreviewName
})
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -98,10 +223,19 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, source)
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(
parent,
node as LGraphNode,
source.sourceWidgetName
)
continue
}
if ('getSlotFromWidget' in node) {
promoteValueWidgetViaSubgraphInput(parent, node as LGraphNode, widget)
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -116,10 +250,45 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, source)
if (!parent.subgraph) continue
const linkedInput = parent.subgraph.inputs.find((input) => {
const linkedWidget = input._widget
return (
linkedWidget &&
isPromotedWidgetView(linkedWidget) &&
linkedWidget.sourceNodeId === source.sourceNodeId &&
linkedWidget.sourceWidgetName === source.sourceWidgetName &&
linkedWidget.disambiguatingSourceNodeId ===
source.disambiguatingSourceNodeId
)
})
if (linkedInput) {
parent.subgraph.removeInput(linkedInput)
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
const hostLocator = createNodeLocatorId(parent.rootGraph.id, parent.id)
const exposure = previewStore
.getExposures(parent.rootGraph.id, hostLocator)
.find(
(entry) =>
entry.sourceNodeId === source.sourceNodeId &&
entry.sourcePreviewName === source.sourceWidgetName
)
if (exposure) {
previewStore.removeExposure(
parent.rootGraph.id,
hostLocator,
exposure.name
)
continue
}
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -152,11 +321,10 @@ export function addWidgetPromotionOptions(
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -189,10 +357,9 @@ export function tryToggleWidgetPromotion() {
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -248,7 +415,6 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -260,14 +426,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
promotePreviewViaExposure(subgraphNode, node, widget.name)
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
@@ -282,19 +441,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
canvasSource
)
) {
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
continue
}
@@ -305,40 +452,39 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
for (const [n, w] of filteredWidgets) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
toPromotionSource(n, w)
)
promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.sourceNodeId)
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.sourceWidgetName
(iw) => iw.name === widget.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
removedEntries.push(widget)
}
return hasWidget
return !hasWidget
})
for (const input of staleInputs) {
subgraph.removeInput(input)
}
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[proxyWidgetUtils] Pruned disconnected promotions',
@@ -350,7 +496,6 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
@@ -360,14 +505,13 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
getPromotableWidgets(interiorNode).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
!isPromotedOnParent(subgraphNode, widget, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})

View File

@@ -13,6 +13,7 @@ import {
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
@@ -280,7 +281,7 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() removes graph-scoped promotion and widget-value state', () => {
test('clear() removes graph-scoped promotion, preview, and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
@@ -293,6 +294,12 @@ describe('Graph Clearing and Callbacks', () => {
sourceWidgetName: 'seed'
})
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graphId, `${graphId}:1`, {
sourceNodeId: '10',
sourcePreviewName: '$$canvas-image-preview'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
@@ -314,6 +321,9 @@ describe('Graph Clearing and Callbacks', () => {
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
previewExposureStore.getExposures(graphId, `${graphId}:1`)
).toHaveLength(1)
graph.clear()
@@ -326,6 +336,9 @@ describe('Graph Clearing and Callbacks', () => {
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)
})
})

View File

@@ -1,6 +1,5 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -10,10 +9,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -380,7 +376,7 @@ export class LGraph
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -1928,13 +1924,6 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1947,75 +1936,6 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }

View File

@@ -77,7 +77,6 @@ export class LiteGraphGlobal {
WIDGET_BGCOLOR = '#222'
WIDGET_OUTLINE_COLOR = '#666'
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
WIDGET_TEXT_COLOR = '#DDD'
WIDGET_SECONDARY_TEXT_COLOR = '#999'

View File

@@ -135,7 +135,6 @@ LiteGraphGlobal {
"WIDGET_BGCOLOR": "#222",
"WIDGET_DISABLED_TEXT_COLOR": "#666",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#DDD",
"allow_multi_output_for_events": true,

View File

@@ -152,6 +152,33 @@ describe('SubgraphNode.serialize (ADR 0009)', () => {
})
describe('previewExposures round-trip', () => {
it('hydrates previewExposures into the store during configure', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const rootGraphId = hostNode.rootGraph.id
const hostLocator = createNodeLocatorId(rootGraphId, hostNode.id)
hostNode.properties.previewExposures = [
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
]
hostNode._internalConfigureAfterSlots()
expect(
usePreviewExposureStore().getExposures(rootGraphId, hostLocator)
).toEqual([
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
})
it('writes previewExposures from the store on serialize', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)

View File

@@ -37,24 +37,17 @@ import {
getPromotedWidgetHostStateName,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
@@ -112,13 +105,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
/**
* Promotions buffered before this node is attached to a graph (`id === -1`).
* They are flushed in `_flushPendingPromotions()` from `_setWidget()` and
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: PromotedWidgetSource[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
@@ -127,7 +113,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotedWidgetSource[]
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
@@ -273,21 +258,17 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const cachedViews = this._promotedViewsCache
if (
cachedViews?.version === this._cacheVersion &&
cachedViews.entriesRef === entries &&
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
const displayNameByViewKey = this._buildDisplayNameByViewKey(linkedEntries)
const reconcileEntries = this._buildLinkedReconcileEntries(linkedEntries)
const views = this._promotedViewManager.reconcile(
reconcileEntries,
@@ -304,7 +285,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
hasMissingBoundSourceWidget,
views
}
@@ -317,268 +297,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _syncPromotions(): void {
if (this.id === -1) return
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries(false)
// Intentionally preserve independent store promotions when linked coverage is partial;
// tests assert that mixed linked/independent states must not collapse to linked-only.
const { mergedEntries } = this._buildPromotionPersistenceState(
entries,
linkedEntries
)
const hasChanged =
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.sourceNodeId !== entries[index]?.sourceNodeId ||
entry.sourceWidgetName !== entries[index]?.sourceWidgetName ||
entry.disambiguatingSourceNodeId !==
entries[index]?.disambiguatingSourceNodeId
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries
}
}
private _buildPromotionPersistenceState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: PromotedWidgetSource[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
}
}
private _collectLinkedAndFallbackEntries(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
entries,
excludedEntryKeys
)
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
prePruneFallbackStoredEntries,
linkedPromotionEntries
)
return {
linkedPromotionEntries,
fallbackStoredEntries
}
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotedWidgetSource[]
): boolean {
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedEntryKeys = new Set(
linkedEntries.map((entry) =>
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
if (sourceNode.type === 'PrimitiveNode') return true
const hasSourceWidget =
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
// If the fallback entry overlaps a linked entry, keep it
// until aliasing can be positively proven.
return linkedEntryKeys.has(
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
})
return !hasFallbackToKeep
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
private _getFallbackStoredEntries(
entries: PromotedWidgetSource[],
excludedEntryKeys: Set<string>
): PromotedWidgetSource[] {
return entries.filter(
(entry) =>
!excludedEntryKeys.has(
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
)
return fallbackStoredEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
prunedEntries.push(entry)
}
return prunedEntries
}
private _resolveConcretePromotionEntryKey(
entry: PromotedWidgetSource
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
return this._makePromotionEntryKey(
String(result.resolved.node.id),
result.resolved.widget.name
)
}
private _getConnectedPromotionEntryKeys(): Set<string> {
const connectedEntryKeys = new Set<string>()
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const widget of connectedWidgets) {
if (!hasWidgetNode(widget)) continue
connectedEntryKeys.add(
this._makePromotionEntryKey(String(widget.node.id), widget.name)
)
}
}
return connectedEntryKeys
this._invalidatePromotedViewsCache()
}
private _buildLinkedReconcileEntries(
@@ -631,18 +350,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
private _makePromotionEntryKey(
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): string {
return makePromotionEntryKey({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
}
private _makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
@@ -661,31 +368,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) {
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
}
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
/** Manages lifecycle of all subgraph event listeners */
private _eventAbortController = new AbortController()
@@ -880,17 +562,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// so resolve by current links would miss this new connection.
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
const source: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: e.detail.widget.name
}
if (
usePromotionStore().isPromoted(this.rootGraph.id, this.id, source)
) {
usePromotionStore().demote(this.rootGraph.id, this.id, source)
}
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
@@ -1078,39 +749,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// ADR 0009: hydrate the runtime PromotionStore index from any legacy
// properties.proxyWidgets present on this host. Hydration is read-only
// — we no longer write back resolved entries to the property; the
// post-configure flush in LGraph.configure forward-ratchets the
// payload into canonical state.
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName, sourceNodeId]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
)
}
return null
}
if (!this.subgraph.getNodeById(nodeId)) return null
return normalizeLegacyProxyWidgetEntry(
this,
nodeId,
widgetName,
sourceNodeId
)
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
usePreviewExposureStore().setExposures(
this.rootGraph.id,
createNodeLocatorId(this.rootGraph.id, this.id),
parsePreviewExposures(this.properties.previewExposures)
)
// Check all inputs for connected widgets
for (const input of this.inputs) {
@@ -1132,12 +775,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
const source: PromotedWidgetSource = {
const hostLocator = createNodeLocatorId(this.rootGraph.id, this.id)
const previewStore = usePreviewExposureStore()
const existing = previewStore
.getExposures(this.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(node.id) &&
exposure.sourcePreviewName === CANVAS_IMAGE_PREVIEW_WIDGET
)
if (existing) continue
previewStore.addExposure(this.rootGraph.id, hostLocator, {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
}
}
@@ -1217,7 +868,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
interiorNode: LGraphNode
) {
this._invalidatePromotedViewsCache()
this._flushPendingPromotions()
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
@@ -1234,34 +884,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
this._removePromotedView(previousView)
}
if (this.id === -1) {
if (
!this._pendingPromotions.some(
(entry) =>
entry.sourceNodeId === nodeId &&
entry.sourceWidgetName === widgetName &&
entry.disambiguatingSourceNodeId === sourceNodeId
)
) {
this._pendingPromotions.push({
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
})
}
} else {
// Add to promotion store
usePromotionStore().promote(this.rootGraph.id, this.id, {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
@@ -1306,18 +931,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
private _flushPendingPromotions() {
if (this.id === -1 || this._pendingPromotions.length === 0) return
for (const entry of this._pendingPromotions) {
usePromotionStore().promote(this.rootGraph.id, this.id, entry)
}
this._pendingPromotions = []
}
override onAdded(_graph: LGraph): void {
this._flushPendingPromotions()
this._syncPromotions()
}
@@ -1513,7 +1127,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
@@ -1544,7 +1157,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
usePromotionStore().setPromotions(this.rootGraph.id, this.id, [])
this._promotedViewManager.clear()
for (const input of this.inputs) {

View File

@@ -17,7 +17,6 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -26,7 +25,6 @@ export interface DrawWidgetOptions {
width: number
/** Synonym for "low quality". */
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
@@ -206,17 +204,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
}
getOutlineColor(suppressPromotedOutline = false) {
const graphId = this.node.graph?.rootGraph.id
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
getOutlineColor(_suppressPromotedOutline = false) {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR

View File

@@ -10,7 +10,6 @@ import {
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -207,20 +206,15 @@ describe('computeProcessedWidgets borderStyle', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies promoted border styling to intermediate promoted widgets', () => {
it('does not apply border styling to promoted widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
slotName: 'text',
promotedLabel: 'Text'
})
const result = computeProcessedWidgets({
@@ -242,13 +236,12 @@ describe('computeProcessedWidgets borderStyle', () => {
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
expect(result[0].simplified.borderStyle).toBeUndefined()
expect(result[0].simplified.label).toBe('Text')
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
it('does not apply border styling to regular widgets', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
@@ -257,17 +250,11 @@ describe('computeProcessedWidgets borderStyle', () => {
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '4',
type: 'SubgraphNode',
widgets: [promotedWidget],
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,

View File

@@ -29,7 +29,6 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -167,7 +166,6 @@ export function computeProcessedWidgets({
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
@@ -249,13 +247,9 @@ export function computeProcessedWidgets({
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
@@ -270,17 +264,9 @@ export function computeProcessedWidgets({
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId

View File

@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
// Mock dependencies
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
@@ -12,12 +10,6 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
isPromotedByAny: isPromotedByAnyMock
})
}))
vi.mock('@/utils/formatUtil', () => ({
generateUUID: () => 'test-uuid'
}))
@@ -114,41 +106,12 @@ describe('DOMWidget Y Position Preservation', () => {
})
})
describe('DOMWidget draw promotion behavior', () => {
describe('DOMWidget draw behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('draws promoted outline for visible promoted widgets', () => {
isPromotedByAnyMock.mockReturnValue(true)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
node.graph = { rootGraph } as never
const onDraw = vi.fn()
const widget = new DOMWidgetImpl({
node,
name: 'seed',
type: 'text',
element: document.createElement('div'),
options: { onDraw }
})
const ctx = createMockContext()
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})
test('does not draw promoted outline when widget is not promoted', () => {
isPromotedByAnyMock.mockReturnValue(false)
test('does not draw an outline for visible widgets', () => {
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
node.graph = { rootGraph } as never
@@ -187,7 +150,6 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
expect(ctx.strokeRect).not.toHaveBeenCalled()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -13,7 +13,6 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<
@@ -125,7 +124,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
declare readonly name: string
declare readonly options: DOMWidgetOptions<V>
declare callback?: (value: V) => void
readonly promotionStore = usePromotionStore()
readonly id: string
@@ -186,30 +184,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
this.options.onDraw?.(this)
return
}
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
if (!isPromoted) {
this.options.onDraw?.(this)
return
}
ctx.save()
const adjustedMargin = this.margin - 1
ctx.beginPath()
ctx.strokeStyle = LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
ctx.strokeRect(
adjustedMargin,
y + adjustedMargin,
widget_width - adjustedMargin * 2,
(this.computedHeight ?? widget_height) - 2 * adjustedMargin
)
ctx.restore()
}
this.options.onDraw?.(this)
}

View File

@@ -57,7 +57,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -70,6 +70,7 @@ import {
isVideoOutput,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'
@@ -187,11 +188,13 @@ export const useLitegraphService = () => {
}
function getPseudoWidgetPreviewTargets(node: SubgraphNode): LGraphNode[] {
const promotionStore = usePromotionStore()
const promotions = promotionStore.getPromotionsRef(
node.rootGraph.id,
node.id
)
const hostLocator = createNodeLocatorId(node.rootGraph.id, node.id)
const promotions = usePreviewExposureStore()
.getExposures(node.rootGraph.id, hostLocator)
.map((exposure) => ({
sourceNodeId: exposure.sourceNodeId,
sourceWidgetName: exposure.sourcePreviewName
}))
const resolved = resolveSubgraphPseudoWidgetCache({
cache: subgraphPseudoWidgetCache.get(node) ?? null,
promotions,