mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 14:16:00 +00:00
feat(subgraph): add link-only promotion system
This commit is contained in:
@@ -393,31 +393,62 @@ export class SubgraphHelper {
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
const widgetEntries = (node.widgets ?? []).flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [
|
||||
[widget.sourceNodeId, widget.sourceWidgetName] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(node.id)
|
||||
)
|
||||
const previewExposures = Array.isArray(
|
||||
serializedNode?.properties?.previewExposures
|
||||
)
|
||||
? serializedNode.properties.previewExposures
|
||||
: []
|
||||
const promotedWidgets = proxyWidgets
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
Array.isArray(entry) &&
|
||||
entry.length >= 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
)
|
||||
.map(
|
||||
([interiorNodeId, widgetName]) =>
|
||||
[interiorNodeId, widgetName] as [string, string]
|
||||
)
|
||||
const previewEntries = previewExposures.flatMap((entry) => {
|
||||
if (
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
'sourceNodeId' in entry &&
|
||||
typeof entry.sourceNodeId === 'string' &&
|
||||
'sourcePreviewName' in entry &&
|
||||
typeof entry.sourcePreviewName === 'string'
|
||||
) {
|
||||
return [
|
||||
[entry.sourceNodeId, entry.sourcePreviewName] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
promotedWidgets: [...widgetEntries, ...previewEntries]
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function getPromotedWidgets(
|
||||
// Read the live promoted widget views from the host node instead of the
|
||||
// serialized proxyWidgets snapshot, which can lag behind the current graph
|
||||
// state during promotion and cleanup flows.
|
||||
return widgets.flatMap((widget) => {
|
||||
const widgetEntries = widgets.flatMap((widget) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
@@ -40,6 +40,29 @@ export async function getPromotedWidgets(
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
const previewExposures = serializedNode?.properties?.previewExposures
|
||||
const previewEntries = Array.isArray(previewExposures)
|
||||
? previewExposures.flatMap((exposure) => {
|
||||
if (
|
||||
typeof exposure === 'object' &&
|
||||
exposure !== null &&
|
||||
'sourceNodeId' in exposure &&
|
||||
typeof exposure.sourceNodeId === 'string' &&
|
||||
'sourcePreviewName' in exposure &&
|
||||
typeof exposure.sourcePreviewName === 'string'
|
||||
) {
|
||||
return [[exposure.sourceNodeId, exposure.sourcePreviewName]]
|
||||
}
|
||||
return []
|
||||
})
|
||||
: []
|
||||
|
||||
return [...widgetEntries, ...previewEntries]
|
||||
}, nodeId)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
@@ -78,12 +101,6 @@ export async function getPromotedWidgetCountByName(
|
||||
nodeId: string,
|
||||
widgetName: string
|
||||
): Promise<number> {
|
||||
return comfyPage.page.evaluate(
|
||||
([id, name]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
return widgets.filter((widget) => widget.name === name).length
|
||||
},
|
||||
[nodeId, widgetName] as const
|
||||
)
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
return promotedWidgets.filter(([, name]) => name === widgetName).length
|
||||
}
|
||||
|
||||
@@ -1,20 +1,149 @@
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import {
|
||||
getPromotedWidgetCount,
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
interface MutableWorkflowNode {
|
||||
id: number
|
||||
pos?: [number, number]
|
||||
widgets_values?: unknown[]
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type MutableWorkflow = ComfyWorkflowJSON & {
|
||||
last_node_id: number
|
||||
nodes: MutableWorkflowNode[]
|
||||
}
|
||||
|
||||
interface HostWidgetSnapshot {
|
||||
name: string
|
||||
sourceNodeId: string | null
|
||||
sourceWidgetName: string | null
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface PrimitiveFanoutSnapshot {
|
||||
hostWidgetNames: string[]
|
||||
hostWidgetValues: HostWidgetSnapshot[]
|
||||
interiorWidgetValues: unknown[]
|
||||
primitiveOutputLinks: unknown
|
||||
primitiveOriginLinkCount: number
|
||||
serializedProperties: Record<string, unknown>
|
||||
}
|
||||
|
||||
function loadPrimitiveFanoutWorkflow(): MutableWorkflow {
|
||||
return JSON.parse(
|
||||
readFileSync(
|
||||
assetPath('subgraphs/subgraph-with-link-and-proxied-primitive.json'),
|
||||
'utf-8'
|
||||
)
|
||||
) as MutableWorkflow
|
||||
}
|
||||
|
||||
function createPrimitiveFanoutMultiHostWorkflow(): ComfyWorkflowJSON {
|
||||
const workflow = loadPrimitiveFanoutWorkflow()
|
||||
const original = workflow.nodes.find((node) => node.id === 2)
|
||||
if (!original) throw new Error('Primitive fanout fixture is missing host 2')
|
||||
|
||||
original.widgets_values = ['first-host', 11]
|
||||
const clone = structuredClone(original)
|
||||
clone.id = 12
|
||||
clone.pos = [900, 409]
|
||||
clone.widgets_values = ['second-host', 22]
|
||||
workflow.nodes.push(clone)
|
||||
workflow.last_node_id = Math.max(workflow.last_node_id, clone.id)
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
function createUnresolvableProxyWorkflow(): ComfyWorkflowJSON {
|
||||
const workflow = loadPrimitiveFanoutWorkflow()
|
||||
const host = workflow.nodes.find((node) => node.id === 2)
|
||||
if (!host) throw new Error('Primitive fanout fixture is missing host 2')
|
||||
|
||||
host.properties = {
|
||||
...host.properties,
|
||||
proxyWidgets: [['9999', 'missing_widget']]
|
||||
}
|
||||
host.widgets_values = ['quarantined-host-value']
|
||||
|
||||
return workflow
|
||||
}
|
||||
|
||||
async function getPrimitiveFanoutSnapshot(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<PrimitiveFanoutSnapshot> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const hostNode = graph.getNodeById(Number(id))
|
||||
if (!hostNode?.isSubgraphNode?.()) {
|
||||
throw new Error(`Host node ${id} is not a SubgraphNode`)
|
||||
}
|
||||
|
||||
const primitiveNode = hostNode.subgraph.getNodeById(4)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === 4).length
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const serializedNode = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
|
||||
return {
|
||||
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
|
||||
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
|
||||
name: widget.name,
|
||||
sourceNodeId:
|
||||
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
|
||||
? widget.sourceNodeId
|
||||
: null,
|
||||
sourceWidgetName:
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
? widget.sourceWidgetName
|
||||
: null,
|
||||
value: widget.value
|
||||
})),
|
||||
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
|
||||
(node.widgets ?? []).map((widget) => widget.value)
|
||||
),
|
||||
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
|
||||
primitiveOriginLinkCount,
|
||||
serializedProperties: serializedNode?.properties ?? {}
|
||||
}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function getSerializedSubgraphNodeProperties(
|
||||
comfyPage: ComfyPage,
|
||||
hostNodeId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const serialized = window.app!.graph!.serialize()
|
||||
const node = serialized.nodes.find(
|
||||
(candidate) => String(candidate.id) === String(id)
|
||||
)
|
||||
return node?.properties ?? {}
|
||||
}, hostNodeId)
|
||||
}
|
||||
|
||||
async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
comfyPage: ComfyPage,
|
||||
hostSubgraphNodeId: string,
|
||||
@@ -41,6 +170,160 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(beforeReload.hostWidgetNames).toContain('value')
|
||||
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(beforeReload.serializedProperties).not.toHaveProperty(
|
||||
'proxyWidgetErrorQuarantine'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
expect(afterReload.interiorWidgetValues).toEqual(
|
||||
beforeReload.interiorWidgetValues
|
||||
)
|
||||
expect(
|
||||
afterReload.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe(
|
||||
beforeReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
)
|
||||
expect(afterReload.primitiveOriginLinkCount).toBe(0)
|
||||
expect(afterReload.serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
})
|
||||
|
||||
test('Multiple SubgraphNode hosts keep independent migrated widget values', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadGraphData(
|
||||
createPrimitiveFanoutMultiHostWorkflow()
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '12'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
const firstHost = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
const secondHost = await getPrimitiveFanoutSnapshot(comfyPage, '12')
|
||||
|
||||
expect(
|
||||
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('second-host')
|
||||
expect(
|
||||
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
|
||||
?.value
|
||||
).toBe('second-host')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const firstAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
|
||||
const secondAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '12')
|
||||
expect(
|
||||
firstAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
firstAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('first-host')
|
||||
expect(
|
||||
secondAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('second-host')
|
||||
expect(
|
||||
secondAfterReload.hostWidgetValues.find(
|
||||
(widget) => widget.sourceNodeId === '1'
|
||||
)?.value
|
||||
).toBe('second-host')
|
||||
})
|
||||
|
||||
test('Nested preview exposures render through serialized chain resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'8'
|
||||
)
|
||||
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(nestedHostProperties.previewExposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '6',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
|
||||
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
|
||||
await expect(nestedSubgraphNode).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
// A host whose only promoted content is a preview exposure has no
|
||||
// node.widgets entries and renders no `.lg-node-widgets` container; the
|
||||
// pseudo-widget surfaces via usePromotedPreviews instead.
|
||||
})
|
||||
|
||||
test('Legacy unresolvable proxy entry is omitted and quarantined on save', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadGraphData(createUnresolvableProxyWorkflow())
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
|
||||
.not.toContain('missing_widget')
|
||||
|
||||
const serializedProperties = await getSerializedSubgraphNodeProperties(
|
||||
comfyPage,
|
||||
'2'
|
||||
)
|
||||
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
|
||||
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'missing_widget'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'quarantined-host-value'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -487,14 +770,15 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
// The legacy `proxyWidgets` entry references an interior nodeId that
|
||||
// doesn't match the existing linked input's PromotedWidgetView source,
|
||||
// so migration creates a second SubgraphInput rather than deduping.
|
||||
// The intent of this test is that no legacy "<id>: <id>:" prefix
|
||||
// leaks into the rendered widget rows.
|
||||
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgetRows).toHaveCount(2)
|
||||
|
||||
for (const row of await widgetRows.all()) {
|
||||
await expect(
|
||||
row.getByLabel('string_a', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
await expect(widgetRows.first()).not.toContainText('6: 3:')
|
||||
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -93,12 +93,11 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
await expect(
|
||||
firstSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
await expect(
|
||||
secondSubgraphNode.locator('.lg-node-widgets')
|
||||
).not.toContainText('$$canvas-image-preview')
|
||||
// Hosts whose only promoted content is preview exposures have empty
|
||||
// node.widgets, so the `.lg-node-widgets` container is not rendered at
|
||||
// all (gated by `<NodeWidgets v-if="nodeData.widgets?.length">`). The
|
||||
// assertion above (count by name returns the right number) already
|
||||
// proves previews don't render as regular widget rows.
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -285,11 +285,11 @@ quarantine.
|
||||
|
||||
## PromotionStore
|
||||
|
||||
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
|
||||
runtime compatibility/index layer for existing consumers, but it is not
|
||||
serialized authority, must not create promotions without linked
|
||||
`SubgraphInput`s, and should be removed once consumers query the standard graph
|
||||
interface directly.
|
||||
`PromotionStore` has been removed. Canonical value-widget exposure is
|
||||
represented by linked `SubgraphInput`s. Canonical preview exposure is
|
||||
represented by host-scoped `properties.previewExposures` /
|
||||
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
|
||||
and must not be reintroduced as runtime authority.
|
||||
|
||||
## Considered options
|
||||
|
||||
@@ -325,4 +325,5 @@ for existing workflow consumers that still assume array order.
|
||||
- Primitive fanout repair is more complex, but avoids breaking common existing
|
||||
workflows.
|
||||
- UI code must migrate with the runtime migration to avoid mixed identity states.
|
||||
- `PromotionStore` has a clear removal path.
|
||||
- `PromotionStore` is removed; callers query linked inputs or preview exposures
|
||||
directly.
|
||||
|
||||
@@ -6,16 +6,17 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Six stores extract entity state out of class instances into centralized, queryable registries:
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -99,62 +100,39 @@ graph LR
|
||||
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
|
||||
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
|
||||
|
||||
## 3. PromotionStore
|
||||
## 3. Linked promoted widgets and preview exposures
|
||||
|
||||
**File:** `src/stores/promotionStore.ts`
|
||||
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
|
||||
by linked `SubgraphInput`s, and display-only previews are represented by
|
||||
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
|
||||
Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
|
||||
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
|
||||
### Runtime shape
|
||||
|
||||
### State Shape
|
||||
```diagram
|
||||
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
|
||||
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
|
||||
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
|
||||
|
||||
```
|
||||
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
|
||||
│ │ │
|
||||
graphId subgraphNodeId ordered promotion entries
|
||||
|
||||
graphRefCounts: Map<UUID, Map<string, number>>
|
||||
│ │ │
|
||||
graphId entryKey count of nodes promoting this widget
|
||||
╭────────────────╮ ╭──────────────────────╮
|
||||
│ Subgraph host │────▶│ PreviewExposureStore │
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
|
||||
### Ref-Counting for O(1) Queries
|
||||
|
||||
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
|
||||
|
||||
```ts
|
||||
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
|
||||
// O(1) lookup: refCounts.get(key) > 0
|
||||
```
|
||||
|
||||
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
|
||||
|
||||
### View Reconciliation Layer
|
||||
|
||||
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
PS["PromotionStore
|
||||
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
|
||||
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
|
||||
(proxy widget)"]
|
||||
PV -->|"resolveDeepest()"| CW["Concrete Widget
|
||||
(leaf node)"]
|
||||
PV -->|"reads value"| WVS["WidgetValueStore"]
|
||||
```
|
||||
|
||||
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
|
||||
| Ref-counted queries | Yes | Efficient global state queries without scanning |
|
||||
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
|
||||
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
|
||||
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
|
||||
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -208,8 +186,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
|
||||
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
|
||||
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
|
||||
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
@@ -222,7 +200,7 @@ graph TD
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, isPromotedByAny)"]
|
||||
(getWidget, preview exposures)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
(LayoutStore)"]
|
||||
@@ -249,13 +227,12 @@ graph TD
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
@@ -289,7 +266,6 @@ graph TD
|
||||
- value → WidgetValueStore
|
||||
- label → WidgetValueStore
|
||||
- disabled → WidgetValueStore
|
||||
- promotion status → PromotionStore
|
||||
- DOM pos/vis → DomWidgetStore"]
|
||||
W_rem["Remains on class:
|
||||
- _node back-ref
|
||||
@@ -333,7 +309,8 @@ graph TD
|
||||
|
||||
subgraph Subgraph["Subgraph (node component)"]
|
||||
S_ext["Extracted:
|
||||
- promotions → PromotionStore"]
|
||||
- value exposure → linked inputs
|
||||
- preview exposure → PreviewExposureStore"]
|
||||
S_rem["Remains on class:
|
||||
- name, description
|
||||
- inputs[], outputs[]
|
||||
@@ -360,15 +337,15 @@ graph TD
|
||||
|
||||
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
|
||||
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
|
||||
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
|
||||
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
|
||||
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
|
||||
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
|
||||
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
|
||||
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
|
||||
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
|
||||
|
||||
### Priority Order for Extraction
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -157,10 +158,12 @@ function handleClick(e: MouseEvent) {
|
||||
}
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const isPromoted = isPromotedWidgetView(widget)
|
||||
const storeId =
|
||||
isPromoted && app.rootGraph?.id
|
||||
? createNodeLocatorId(app.rootGraph.id, node.id)
|
||||
: node.id
|
||||
const storeName = widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, provide, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -63,6 +63,17 @@ useEventListener(
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
// `LGraph.trigger()` invokes `onTrigger` synchronously but does not dispatch
|
||||
// on `events`, so chain through `onTrigger` to react to slot-label renames.
|
||||
const previousOnTrigger = app.rootGraph.onTrigger
|
||||
app.rootGraph.onTrigger = (event) => {
|
||||
previousOnTrigger?.(event)
|
||||
if (event.type === 'node:slot-label:changed') triggerRef(graphNodes)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
if (app.rootGraph.onTrigger === undefined) return
|
||||
app.rootGraph.onTrigger = previousOnTrigger
|
||||
})
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
|
||||
@@ -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,12 @@ 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
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
})
|
||||
}
|
||||
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: String(widgetNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputAtIndex
|
||||
} 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 +37,6 @@ const { node } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -55,9 +58,31 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
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,12 +121,9 @@ 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)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -190,12 +182,7 @@ function setDraggableState() {
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
reorderSubgraphInputAtIndex(node, oldPosition, newPosition)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
@@ -201,64 +199,4 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(onResetToDefault).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'CUSTOM'
|
||||
})
|
||||
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
|
||||
id: 4,
|
||||
rootGraph: { id: 'graph-test' },
|
||||
computeSize: vi.fn(),
|
||||
size: [300, 150]
|
||||
})
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 4,
|
||||
type: 'SubgraphNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
const widget = {
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
value: 'value',
|
||||
label: 'Text',
|
||||
options: {},
|
||||
y: 0,
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
} as IBaseWidget
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(WidgetActions, {
|
||||
props: {
|
||||
widget,
|
||||
node,
|
||||
label: 'Text',
|
||||
parents: [parentSubgraphNode],
|
||||
isShownOnParents: true
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Hide input/ }))
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted('graph-test', 4, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,7 +41,6 @@ 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)
|
||||
@@ -82,16 +79,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 {
|
||||
|
||||
324
src/components/rightSidePanel/subgraph/SubgraphEditor.test.ts
Normal file
324
src/components/rightSidePanel/subgraph/SubgraphEditor.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
import {
|
||||
getSourceNodeId,
|
||||
promoteValueWidgetViaSubgraphInput
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: vi.fn() })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
subgraphStore: {
|
||||
shown: 'Shown',
|
||||
hidden: 'Hidden',
|
||||
hideAll: 'Hide all',
|
||||
showAll: 'Show all',
|
||||
addRecommended: 'Add recommended'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noneSearchDesc: 'No results'
|
||||
},
|
||||
g: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Search'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('SubgraphEditor', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders preview exposures after promoted inputs without drag handles', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
subgraph.add(previewNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
usePreviewExposureStore().addExposure(
|
||||
subgraph.rootGraph.id,
|
||||
String(host.id),
|
||||
{
|
||||
sourceNodeId: String(previewNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second', '$$canvas-image-preview'])
|
||||
expect(
|
||||
within(screen.getByTestId('draggable-list'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
expect(
|
||||
within(shown).getAllByTestId('subgraph-widget-drag-handle')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reorders node widgets from dragged promoted input order when widget names repeat', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('seed', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'seed', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('seed', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'seed', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
it('rerenders promoted widgets in dragged input order', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('subgraph-editor-shown-section'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('subgraph-editor-shown-section'))
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('serializes promoted widget values in dragged input order', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('sends dragged text input values to the matching prompt targets', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
const graph = host.graph!
|
||||
graph.add(host)
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
firstNode.comfyClass = 'FirstNode'
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
secondNode.comfyClass = 'SecondNode'
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
useCanvasStore().selectedItems = [host]
|
||||
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
DraggableList: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
data-testid="reverse-promoted-widgets"
|
||||
@click="$emit('update:modelValue', [...modelValue].reverse())"
|
||||
>
|
||||
<slot drag-class="draggable-item" />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(output[`${host.id}:${firstNode.id}`].inputs.text).toBe('first value')
|
||||
expect(output[`${host.id}:${secondNode.id}`].inputs.text).toBe(
|
||||
'second value'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -14,7 +13,8 @@ import {
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -22,23 +22,17 @@ 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 { 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 inputOrderVersion = ref(0)
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
@@ -51,56 +45,71 @@ 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]]
|
||||
}
|
||||
)
|
||||
return [...getActivePromotedWidgets(node), ...getActivePreviewWidgets(node)]
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
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
|
||||
}))
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
updateActiveWidgets(value, activeWidgets.value)
|
||||
}
|
||||
})
|
||||
|
||||
const activePromotedWidgets = computed<WidgetItem[]>({
|
||||
get() {
|
||||
const node = activeNode.value
|
||||
return node ? getActivePromotedWidgets(node) : []
|
||||
},
|
||||
set(value: WidgetItem[]) {
|
||||
updateActiveWidgets(value, activePromotedWidgets.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedWidgets(node: SubgraphNode): WidgetItem[] {
|
||||
void inputOrderVersion.value
|
||||
return node.widgets.flatMap((widget): WidgetItem[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [[sourceNode, widget]]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewWidgets(node: SubgraphNode): WidgetItem[] {
|
||||
const hostLocator = String(node.id)
|
||||
return 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]] : []
|
||||
})
|
||||
}
|
||||
|
||||
function updateActiveWidgets(value: WidgetItem[], currentItems: WidgetItem[]) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map(([, widget]) => widget)
|
||||
)
|
||||
inputOrderVersion.value += 1
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
|
||||
const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
@@ -119,14 +128,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[]>(() => {
|
||||
@@ -155,6 +158,14 @@ const filteredActive = computed<WidgetItem[]>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const filteredActivePromoted = computed<WidgetItem[]>(() =>
|
||||
filteredActive.value.filter(([, widget]) => isPromotedWidgetView(widget))
|
||||
)
|
||||
|
||||
const filteredActivePreviews = computed<WidgetItem[]>(() =>
|
||||
filteredActive.value.filter(([, widget]) => !isPromotedWidgetView(widget))
|
||||
)
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
@@ -259,9 +270,9 @@ onMounted(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activePromotedWidgets">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
v-for="[node, widget] in filteredActivePromoted"
|
||||
:key="toKey([node, widget])"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
@@ -271,6 +282,18 @@ onMounted(() => {
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</DraggableList>
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActivePreviews"
|
||||
:key="toKey([node, widget])"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:is-draggable="false"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -61,6 +61,7 @@ const icon = computed(() =>
|
||||
</Button>
|
||||
<div
|
||||
v-if="isDraggable"
|
||||
data-testid="subgraph-widget-drag-handle"
|
||||
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
@@ -207,7 +206,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
@@ -403,37 +401,6 @@ describe('Subgraph output slot label reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('interior')
|
||||
interiorNode.id = 10
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidget = vueNode?.widgets?.find(
|
||||
(widget) => widget.name === '$$canvas-image-preview'
|
||||
)
|
||||
|
||||
expect(promotedWidget).toBeDefined()
|
||||
expect(promotedWidget?.options?.canvasOnly).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Nested promoted widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -476,118 +443,6 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(independentNode.id),
|
||||
sourceWidgetName: 'string_a'
|
||||
})
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const firstTextNode = new LGraphNode('FirstTextNode')
|
||||
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
||||
innerSubgraph.add(firstTextNode)
|
||||
|
||||
const secondTextNode = new LGraphNode('SecondTextNode')
|
||||
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
||||
innerSubgraph.add(secondTextNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
id: 3,
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
||||
const graph = outerSubgraphNode.graph as LGraph
|
||||
graph.add(outerSubgraphNode)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
innerSubgraphNode.rootGraph.id,
|
||||
innerSubgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
||||
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
||||
]
|
||||
)
|
||||
|
||||
usePromotionStore().setPromotions(
|
||||
outerSubgraphNode.rootGraph.id,
|
||||
outerSubgraphNode.id,
|
||||
[
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(firstTextNode.id)
|
||||
},
|
||||
{
|
||||
sourceNodeId: String(innerSubgraphNode.id),
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: String(secondTextNode.id)
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'text'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
||||
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promoted widget sourceExecutionId', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
@@ -227,18 +228,15 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromotedSourceByInputName(inputName: string): {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
} | null {
|
||||
function resolvePromotedSourceByInputName(
|
||||
inputName: string
|
||||
): PromotedWidgetSource | null {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
|
||||
if (!resolvedTarget) return null
|
||||
|
||||
return {
|
||||
sourceNodeId: resolvedTarget.nodeId,
|
||||
sourceWidgetName: resolvedTarget.widgetName,
|
||||
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
|
||||
sourceWidgetName: resolvedTarget.widgetName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,10 +254,9 @@ function safeWidgetMapper(
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const directSource = {
|
||||
const directSource: PromotedWidgetSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
@@ -306,8 +303,7 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -320,11 +316,7 @@ function safeWidgetMapper(
|
||||
const effectiveWidget = sourceWidget ?? widget
|
||||
|
||||
const localId = isPromotedWidgetView(widget)
|
||||
? String(
|
||||
sourceNode?.id ??
|
||||
promotedSource?.disambiguatingSourceNodeId ??
|
||||
promotedSource?.sourceNodeId
|
||||
)
|
||||
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
|
||||
: undefined
|
||||
const nodeId =
|
||||
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
|
||||
@@ -382,6 +374,11 @@ function buildSlotMetadata(
|
||||
inputs?.forEach((input, index) => {
|
||||
let originNodeId: string | undefined
|
||||
let originOutputName: string | undefined
|
||||
// Promotion via SubgraphInput materialises a real link from the
|
||||
// SUBGRAPH_INPUT sentinel into the interior widget's input slot.
|
||||
// That link is internal plumbing — not an external connection — so
|
||||
// exclude it from `linked` (which downstream renders as disabled).
|
||||
let isPromotionLink = false
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
@@ -389,12 +386,13 @@ function buildSlotMetadata(
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
isPromotionLink = link.origin_id === SUBGRAPH_INPUT_ID
|
||||
}
|
||||
}
|
||||
|
||||
const slotInfo: WidgetSlotMetadata = {
|
||||
index,
|
||||
linked: input.link != null,
|
||||
linked: input.link != null && !isPromotionLink,
|
||||
originNodeId,
|
||||
originOutputName,
|
||||
type: String(input.type)
|
||||
|
||||
@@ -9,19 +9,27 @@ 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'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
||||
| 'nodeOutputs'
|
||||
| 'nodeOutputsByExecutionId'
|
||||
| 'nodePreviewImages'
|
||||
| 'nodePreviewImagesByExecutionId'
|
||||
| 'getNodeImageUrls'
|
||||
| 'getNodeImageUrlsByExecutionId'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
||||
)
|
||||
const getNodeImageUrlsByExecutionId = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrlsByExecutionId']>()
|
||||
)
|
||||
const useNodeOutputStoreMock = vi.hoisted(() =>
|
||||
vi.fn<() => MockNodeOutputStore>()
|
||||
)
|
||||
@@ -35,8 +43,15 @@ vi.mock('@/stores/nodeOutputStore', () => {
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
nodeOutputsByExecutionId: reactive<
|
||||
MockNodeOutputStore['nodeOutputsByExecutionId']
|
||||
>({}),
|
||||
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
||||
getNodeImageUrls
|
||||
nodePreviewImagesByExecutionId: reactive<
|
||||
MockNodeOutputStore['nodePreviewImagesByExecutionId']
|
||||
>({}),
|
||||
getNodeImageUrls,
|
||||
getNodeImageUrlsByExecutionId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +98,18 @@ function seedPreviewImages(
|
||||
}
|
||||
}
|
||||
|
||||
function exposePreview(
|
||||
setup: ReturnType<typeof createSetup>,
|
||||
sourceNodeId: string,
|
||||
sourcePreviewName = '$$canvas-image-preview'
|
||||
) {
|
||||
usePreviewExposureStore().addExposure(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
String(setup.subgraphNode.id),
|
||||
{ sourceNodeId, sourcePreviewName }
|
||||
)
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
|
||||
@@ -90,6 +117,7 @@ describe(usePromotedPreviews, () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
getNodeImageUrls.mockReset()
|
||||
getNodeImageUrlsByExecutionId.mockReset()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
@@ -109,11 +137,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 +145,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])
|
||||
@@ -143,14 +162,41 @@ describe(usePromotedPreviews, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates direct legacy host exposure keys while reading previews', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
||||
const store = usePreviewExposureStore()
|
||||
const rootGraphId = setup.subgraphNode.rootGraph.id
|
||||
store.addExposure(
|
||||
rootGraphId,
|
||||
createNodeLocatorId(rootGraphId, setup.subgraphNode.id),
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(
|
||||
store.getExposures(rootGraphId, String(setup.subgraphNode.id))
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '10',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
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 +208,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 +227,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 +246,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 +266,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 +288,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 +296,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])
|
||||
@@ -306,4 +315,169 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
||||
})
|
||||
|
||||
it('renders leaf media exposed through a nested subgraph host', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(innerHost.id),
|
||||
{
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(outerSetup.subgraphNode.id),
|
||||
{
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=leaf.png']
|
||||
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
|
||||
node === leafNode ? mockUrls : []
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(
|
||||
() => outerSetup.subgraphNode
|
||||
)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('migrates nested legacy host exposure keys while resolving leaf media', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
|
||||
const rootGraphId = outerSetup.subgraphNode.rootGraph.id
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(
|
||||
rootGraphId,
|
||||
createNodeLocatorId(rootGraphId, innerHost.id),
|
||||
{
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
store.addExposure(rootGraphId, String(outerSetup.subgraphNode.id), {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const mockUrls = ['/view?filename=leaf.png']
|
||||
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
|
||||
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
|
||||
node === leafNode ? mockUrls : []
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(
|
||||
() => outerSetup.subgraphNode
|
||||
)
|
||||
const nestedHostLocator = `${String(outerSetup.subgraphNode.id)}:${innerHost.id}`
|
||||
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphId, nestedHostLocator)).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: '10',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
|
||||
const innerSetup = createSetup()
|
||||
const leafNode = addInteriorNode(innerSetup, {
|
||||
id: 10,
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const outerSetup = createSetup()
|
||||
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
|
||||
outerSetup.subgraph.add(innerHost)
|
||||
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
|
||||
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
|
||||
const firstHostLocator = String(firstHost.id)
|
||||
const secondHostLocator = String(secondHost.id)
|
||||
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
|
||||
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
|
||||
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
|
||||
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId[firstLeafExecutionId] = [
|
||||
'blob:first'
|
||||
]
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId[secondLeafExecutionId] = [
|
||||
'blob:second'
|
||||
]
|
||||
getNodeImageUrlsByExecutionId.mockImplementation((executionId) => {
|
||||
if (executionId === firstLeafExecutionId) return ['blob:first']
|
||||
if (executionId === secondLeafExecutionId) return ['blob:second']
|
||||
return undefined
|
||||
})
|
||||
|
||||
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
|
||||
[
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: ['blob:first']
|
||||
}
|
||||
]
|
||||
)
|
||||
expect(
|
||||
usePromotedPreviews(() => secondHost).promotedPreviews.value
|
||||
).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
type: 'image',
|
||||
urls: ['blob:second']
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,41 +4,128 @@ import { computed, toValue } from 'vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
interface PromotedPreview {
|
||||
/** Source node id resolved on the host's interior subgraph. */
|
||||
sourceNodeId: string
|
||||
/** Canonical preview name on the source widget (typically `$$`-prefixed). */
|
||||
sourceWidgetName: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
urls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns reactive preview media from promoted `$$` pseudo-widgets
|
||||
* on a SubgraphNode. Each promoted preview interior node produces
|
||||
* a separate entry so they render independently.
|
||||
* Returns reactive preview media exposed by a host SubgraphNode.
|
||||
*
|
||||
* 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 promotionStore = usePromotionStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
|
||||
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
const pseudoEntries = entries.filter((e) =>
|
||||
e.sourceWidgetName.startsWith('$$')
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
const legacyHostLocator = createNodeLocatorId(rootGraphId, node.id)
|
||||
|
||||
const instanceExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
hostLocator
|
||||
)
|
||||
if (!pseudoEntries.length) return []
|
||||
let exposures = instanceExposures
|
||||
if (!exposures.length) {
|
||||
const legacyExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
legacyHostLocator
|
||||
)
|
||||
if (legacyExposures.length) {
|
||||
previewExposureStore.setExposures(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
legacyExposures
|
||||
)
|
||||
exposures = legacyExposures
|
||||
}
|
||||
}
|
||||
|
||||
const exposurePairs = exposures.map((exposure) => ({
|
||||
exposureName: exposure.name,
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourceWidgetName: exposure.sourcePreviewName
|
||||
}))
|
||||
|
||||
if (!exposurePairs.length) return []
|
||||
|
||||
const previews: PromotedPreview[] = []
|
||||
const hostNodesByLocator = new Map<string, SubgraphNode>([
|
||||
[hostLocator, node]
|
||||
])
|
||||
|
||||
for (const entry of pseudoEntries) {
|
||||
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
|
||||
const resolveNestedHost = (
|
||||
rootGraphId: UUID,
|
||||
currentHostLocator: string,
|
||||
sourceNodeId: string
|
||||
) => {
|
||||
const currentHost = hostNodesByLocator.get(currentHostLocator)
|
||||
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
|
||||
if (!(sourceNode instanceof SubgraphNode)) return undefined
|
||||
|
||||
const nestedHostLocator = `${currentHostLocator}:${sourceNode.id}`
|
||||
const legacyNestedHostLocator = createNodeLocatorId(
|
||||
rootGraphId,
|
||||
sourceNode.id
|
||||
)
|
||||
const nestedExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
nestedHostLocator
|
||||
)
|
||||
if (!nestedExposures.length) {
|
||||
const definitionExposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
String(sourceNode.id)
|
||||
)
|
||||
const legacyExposures = definitionExposures.length
|
||||
? definitionExposures
|
||||
: previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
legacyNestedHostLocator
|
||||
)
|
||||
if (legacyExposures.length) {
|
||||
previewExposureStore.setExposures(
|
||||
rootGraphId,
|
||||
nestedHostLocator,
|
||||
legacyExposures
|
||||
)
|
||||
}
|
||||
}
|
||||
hostNodesByLocator.set(nestedHostLocator, sourceNode)
|
||||
return { rootGraphId, hostNodeLocator: nestedHostLocator }
|
||||
}
|
||||
|
||||
for (const pair of exposurePairs) {
|
||||
const resolved = previewExposureStore.resolveChain(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
pair.exposureName,
|
||||
resolveNestedHost
|
||||
)
|
||||
const leaf = resolved?.leaf ?? {
|
||||
sourceNodeId: pair.sourceNodeId,
|
||||
sourcePreviewName: pair.sourceWidgetName
|
||||
}
|
||||
const leafHostLocator =
|
||||
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
|
||||
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
|
||||
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
|
||||
if (!interiorNode) continue
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
@@ -46,15 +133,29 @@ export function usePromotedPreviews(
|
||||
// app.nodeOutputs / app.nodePreviewImages, so without this
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.sourceNodeId
|
||||
leafHost.subgraph.id,
|
||||
leaf.sourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
|
||||
const leafExecutionId = `${leafHostLocator}:${leaf.sourceNodeId}`
|
||||
const reactiveExecutionOutputs =
|
||||
nodeOutputStore.nodeOutputsByExecutionId?.[leafExecutionId]
|
||||
const reactiveExecutionPreviews =
|
||||
nodeOutputStore.nodePreviewImagesByExecutionId?.[leafExecutionId]
|
||||
if (
|
||||
!reactiveOutputs?.images?.length &&
|
||||
!reactivePreviews?.length &&
|
||||
!reactiveExecutionOutputs?.images?.length &&
|
||||
!reactiveExecutionPreviews?.length
|
||||
)
|
||||
continue
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
const urls =
|
||||
nodeOutputStore.getNodeImageUrlsByExecutionId?.(
|
||||
leafExecutionId,
|
||||
interiorNode
|
||||
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
const type =
|
||||
@@ -65,8 +166,8 @@ export function usePromotedPreviews(
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
sourceNodeId: leaf.sourceNodeId,
|
||||
sourceWidgetName: leaf.sourcePreviewName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
|
||||
@@ -105,7 +105,11 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('returns original entry when prefix cannot be resolved', () => {
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
// ADR 0009: each SubgraphNode is opaque, so legacy nested
|
||||
// disambiguator-based lookup no longer reaches deep widgets. The
|
||||
// prefix is preserved as `disambiguatingSourceNodeId` lookup metadata
|
||||
// for migration tooling.
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
@@ -116,7 +120,8 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: '999: nonexistent_widget'
|
||||
sourceWidgetName: 'nonexistent_widget',
|
||||
disambiguatingSourceNodeId: '999'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,89 +1,54 @@
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
|
||||
|
||||
function canResolve(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).status === 'resolved'
|
||||
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
|
||||
'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
function tryResolveCandidate(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string,
|
||||
disambiguator?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
|
||||
return undefined
|
||||
|
||||
return {
|
||||
sourceWidgetName: widgetName,
|
||||
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
|
||||
}
|
||||
interface StrippedPrefix {
|
||||
sourceWidgetName: string
|
||||
/** Deepest legacy `n: ` prefix removed from the original widget name. */
|
||||
deepestPrefixId?: string
|
||||
}
|
||||
|
||||
function resolveLegacyPrefixedEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
|
||||
let remaining = sourceWidgetName
|
||||
|
||||
let deepestPrefixId: string | undefined
|
||||
while (true) {
|
||||
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
|
||||
if (!match) return undefined
|
||||
|
||||
const [, legacySourceNodeId, unprefixed] = match
|
||||
remaining = unprefixed
|
||||
|
||||
const disambiguators = [
|
||||
legacySourceNodeId,
|
||||
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
|
||||
undefined
|
||||
]
|
||||
|
||||
for (const disambiguator of disambiguators) {
|
||||
const resolved = tryResolveCandidate(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
remaining,
|
||||
disambiguator
|
||||
)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
|
||||
deepestPrefixId = match[1]
|
||||
remaining = match[2]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a legacy `proxyWidgets` entry.
|
||||
*
|
||||
* Under ADR 0009 each `SubgraphNode` is opaque, so the canonical state never
|
||||
* resolves through deep nested identities. This helper still recognizes the
|
||||
* legacy `"<id>: <name>"` prefix encoding and surfaces the deepest prefix as
|
||||
* `disambiguatingSourceNodeId` so migration tooling can preserve it as
|
||||
* lookup metadata. The bare entry is returned unchanged when it already
|
||||
* resolves at the immediate level.
|
||||
*/
|
||||
export function normalizeLegacyProxyWidgetEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
): LegacyProxyEntrySource {
|
||||
if (canResolve(hostNode, sourceNodeId, sourceWidgetName)) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
@@ -91,19 +56,13 @@ export function normalizeLegacyProxyWidgetEntry(
|
||||
}
|
||||
}
|
||||
|
||||
const patch = resolveLegacyPrefixedEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const stripped = stripLegacyPrefixes(sourceWidgetName)
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
sourceWidgetName: stripped.sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
})
|
||||
|
||||
313
src/core/graph/subgraph/migration/classifyProxyEntry.test.ts
Normal file
313
src/core/graph/subgraph/migration/classifyProxyEntry.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
|
||||
import type {
|
||||
LegacyProxyEntrySource,
|
||||
PromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function makeSource(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): LegacyProxyEntrySource {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
describe(classifyProxyEntry, () => {
|
||||
describe('alreadyLinked branch', () => {
|
||||
it('returns alreadyLinked when an input already represents the entry', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('value')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: 'seed_link'
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines as ambiguous when canonical inputs share the same identity, even if the legacy entry has a disambiguator', () => {
|
||||
// ADR 0009: canonical PromotedWidgetView no longer carries a
|
||||
// `disambiguatingSourceNodeId`, so two inputs sharing the same
|
||||
// (sourceNodeId, sourceWidgetName) cannot be told apart by the
|
||||
// classifier. The legacy entry's disambiguator is metadata only and
|
||||
// does not break the tie.
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
for (const inputName of ['first_seed', 'second_seed']) {
|
||||
const input = host.addInput(inputName, '*')
|
||||
input._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
}
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed', 'second')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines ambiguous already-linked inputs without a disambiguator', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
for (const inputName of ['first_seed', 'second_seed']) {
|
||||
const input = host.addInput(inputName, '*')
|
||||
input._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
}
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('quarantine branches', () => {
|
||||
it('quarantines when source node is missing', () => {
|
||||
const host = buildHost()
|
||||
const normalized = makeSource('999', 'seed')
|
||||
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines when source widget is missing on the source node', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'nonexistent')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', '*')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const normalized = makeSource(String(primitive.id), 'value')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview branch', () => {
|
||||
it('classifies $$-prefixed names as preview exposure', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(
|
||||
String(innerNode.id),
|
||||
'$$canvas-image-preview'
|
||||
)
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('preview')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies type:preview serialize:false widgets as preview exposure', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
const widget = innerNode.addWidget('text', 'videopreview', '', () => {})
|
||||
widget.type = 'preview'
|
||||
widget.serialize = false
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'videopreview')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('preview')
|
||||
expect(result.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: 'videopreview'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('value-widget branch', () => {
|
||||
it('plans a createSubgraphInput when the widget exists and is not linked', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 42, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const normalized = makeSource(String(innerNode.id), 'seed')
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized,
|
||||
cohort: [normalized]
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
classification: 'value',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive fanout branch', () => {
|
||||
it('emits primitiveBypass with target list when cohort points at the same primitive', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const targetA = new LGraphNode('TargetA')
|
||||
targetA.addInput('value', 'INT')
|
||||
targetA.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetA)
|
||||
|
||||
const targetB = new LGraphNode('TargetB')
|
||||
targetB.addInput('value', 'INT')
|
||||
targetB.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetB)
|
||||
|
||||
primitive.connect(0, targetA, 0)
|
||||
primitive.connect(0, targetB, 0)
|
||||
|
||||
const sourceA = makeSource(String(primitive.id), 'seed')
|
||||
// Cohort has 2 entries pointing at the primitive (one per target).
|
||||
const cohort = [sourceA, sourceA]
|
||||
|
||||
const result = classifyProxyEntry({
|
||||
hostNode: host,
|
||||
normalized: sourceA,
|
||||
cohort
|
||||
})
|
||||
|
||||
expect(result.classification).toBe('primitiveFanout')
|
||||
expect(result.plan.kind).toBe('primitiveBypass')
|
||||
if (result.plan.kind !== 'primitiveBypass') return
|
||||
expect(result.plan.primitiveNodeId).toBe(primitive.id)
|
||||
expect(result.plan.sourceWidgetName).toBe('seed')
|
||||
expect(result.plan.targets).toHaveLength(2)
|
||||
expect(result.plan.targets.map((t) => t.targetNodeId)).toEqual(
|
||||
expect.arrayContaining([targetA.id, targetB.id])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
168
src/core/graph/subgraph/migration/classifyProxyEntry.ts
Normal file
168
src/core/graph/subgraph/migration/classifyProxyEntry.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
MigrationPlan,
|
||||
PrimitiveBypassTargetRef,
|
||||
ProxyEntryClassification
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
interface ClassificationResult {
|
||||
classification: ProxyEntryClassification
|
||||
plan: MigrationPlan
|
||||
}
|
||||
|
||||
interface ClassifyProxyEntryArgs {
|
||||
hostNode: SubgraphNode
|
||||
normalized: LegacyProxyEntrySource
|
||||
/** All proxy entries this planner pass is considering — needed to detect primitive fan-out. */
|
||||
cohort: readonly LegacyProxyEntrySource[]
|
||||
}
|
||||
|
||||
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
|
||||
|
||||
type LinkedInputMatch =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'one'; inputName: string }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
function findLinkedSubgraphInputMatch(
|
||||
hostNode: SubgraphNode,
|
||||
normalized: LegacyProxyEntrySource
|
||||
): LinkedInputMatch {
|
||||
const matches: string[] = []
|
||||
for (const input of hostNode.inputs) {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) continue
|
||||
if (
|
||||
widget.sourceNodeId === normalized.sourceNodeId &&
|
||||
widget.sourceWidgetName === normalized.sourceWidgetName
|
||||
) {
|
||||
matches.push(input.name)
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) return { kind: 'none' }
|
||||
if (matches.length === 1) return { kind: 'one', inputName: matches[0] }
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
function collectPrimitiveTargets(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const targets: PrimitiveBypassTargetRef[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) continue
|
||||
targets.push({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function cohortReferencesPrimitive(
|
||||
cohort: readonly LegacyProxyEntrySource[],
|
||||
primitiveNodeId: string
|
||||
): boolean {
|
||||
let count = 0
|
||||
for (const entry of cohort) {
|
||||
if (entry.sourceNodeId === primitiveNodeId) {
|
||||
count += 1
|
||||
if (count >= 2) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function classifyProxyEntry(
|
||||
args: ClassifyProxyEntryArgs
|
||||
): ClassificationResult {
|
||||
const { hostNode, normalized, cohort } = args
|
||||
|
||||
const linkedInput = findLinkedSubgraphInputMatch(hostNode, normalized)
|
||||
if (linkedInput.kind === 'one') {
|
||||
return {
|
||||
classification: 'value',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: linkedInput.inputName }
|
||||
}
|
||||
}
|
||||
if (linkedInput.kind === 'ambiguous') {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
|
||||
if (!sourceNode) {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
|
||||
const targets = collectPrimitiveTargets(hostNode, sourceNode)
|
||||
const cohortDuplicated = cohortReferencesPrimitive(
|
||||
cohort,
|
||||
normalized.sourceNodeId
|
||||
)
|
||||
if (targets.length >= 1 || cohortDuplicated) {
|
||||
return {
|
||||
classification: 'primitiveFanout',
|
||||
plan: {
|
||||
kind: 'primitiveBypass',
|
||||
primitiveNodeId: sourceNode.id,
|
||||
sourceWidgetName: normalized.sourceWidgetName,
|
||||
targets
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
const promotableWidgets = getPromotableWidgets(sourceNode)
|
||||
const sourceWidget = promotableWidgets.find(
|
||||
(w) => w.name === normalized.sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return {
|
||||
classification: 'unknown',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.sourceWidgetName.startsWith('$$') ||
|
||||
isPreviewPseudoWidget(sourceWidget)
|
||||
) {
|
||||
return {
|
||||
classification: 'preview',
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classification: 'value',
|
||||
plan: {
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/core/graph/subgraph/migration/migratePreviewExposure.test.ts
Normal file
223
src/core/graph/subgraph/migration/migratePreviewExposure.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
|
||||
import type { ResolveNestedHostFn } from '@/stores/previewExposureStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.graph!.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function buildEntry(args: {
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}): PendingMigrationEntry {
|
||||
return {
|
||||
normalized: {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourcePreviewName
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: HOST_VALUE_HOLE,
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: args.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe(migratePreviewExposure, () => {
|
||||
it('adds an exposure for a $$-prefixed preview source', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
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'
|
||||
})
|
||||
const locator = String(host.id)
|
||||
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('produces a unique name on collision via nextUniqueName', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
const otherInner = new LGraphNode('OtherInner')
|
||||
host.subgraph.add(otherInner)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const locator = String(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(otherInner.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.previewName).toBe('$$canvas-image-preview_1')
|
||||
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 = String(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()
|
||||
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: '999',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}),
|
||||
store
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
|
||||
})
|
||||
|
||||
it('round-trips through resolveChain across an outer host into an inner host', () => {
|
||||
// Set up an inner host with a leaf preview exposure, and a separate outer
|
||||
// host whose interior contains a placeholder for the inner host. The
|
||||
// chain walker is graph-agnostic, so we wire the nested-host edge via
|
||||
// the resolver callback.
|
||||
const innerSubgraph = createTestSubgraph({ name: 'Inner' })
|
||||
const innerHost = createTestSubgraphNode(innerSubgraph)
|
||||
innerHost.graph!.add(innerHost)
|
||||
const innerLeaf = new LGraphNode('Leaf')
|
||||
innerSubgraph.add(innerLeaf)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ name: 'Outer' })
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerHost.graph!.add(outerHost)
|
||||
|
||||
const placeholder = new LGraphNode('PlaceholderInnerHost')
|
||||
outerSubgraph.add(placeholder)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const innerLocator = String(innerHost.id)
|
||||
const outerLocator = String(outerHost.id)
|
||||
|
||||
// Inner host: the leaf exposure (canonical $$ name) the outer chain
|
||||
// ultimately resolves to.
|
||||
store.addExposure(innerHost.rootGraph.id, innerLocator, {
|
||||
sourceNodeId: String(innerLeaf.id),
|
||||
sourcePreviewName: '$$inner-preview'
|
||||
})
|
||||
|
||||
// Outer host: migrate an entry whose source points at the placeholder
|
||||
// (representing the inner host inside outer's interior).
|
||||
const result = migratePreviewExposure({
|
||||
hostNode: outerHost,
|
||||
entry: {
|
||||
normalized: {
|
||||
sourceNodeId: String(placeholder.id),
|
||||
sourceWidgetName: '$$inner-preview'
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: HOST_VALUE_HOLE,
|
||||
plan: {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$inner-preview'
|
||||
}
|
||||
},
|
||||
store
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const resolveNestedHost: ResolveNestedHostFn = (
|
||||
_rootGraphId,
|
||||
_hostLocator,
|
||||
sourceNodeId
|
||||
) =>
|
||||
sourceNodeId === String(placeholder.id)
|
||||
? { rootGraphId: innerHost.rootGraph.id, hostNodeLocator: innerLocator }
|
||||
: undefined
|
||||
|
||||
const chain = store.resolveChain(
|
||||
outerHost.rootGraph.id,
|
||||
outerLocator,
|
||||
'$$inner-preview',
|
||||
resolveNestedHost
|
||||
)
|
||||
|
||||
expect(chain).toBeDefined()
|
||||
expect(chain?.steps).toHaveLength(2)
|
||||
expect(chain?.leaf.sourceNodeId).toBe(String(innerLeaf.id))
|
||||
expect(chain?.leaf.sourcePreviewName).toBe('$$inner-preview')
|
||||
})
|
||||
})
|
||||
68
src/core/graph/subgraph/migration/migratePreviewExposure.ts
Normal file
68
src/core/graph/subgraph/migration/migratePreviewExposure.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
type MigratePreviewExposureResult =
|
||||
| { ok: true; previewName: string }
|
||||
| { ok: false; reason: 'missingSourceNode' | 'missingSourceWidget' }
|
||||
|
||||
interface MigratePreviewExposureArgs {
|
||||
hostNode: SubgraphNode
|
||||
entry: PendingMigrationEntry
|
||||
/** Pinia store action — pass `usePreviewExposureStore()` from the caller. */
|
||||
store: ReturnType<typeof usePreviewExposureStore>
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a single legacy preview-shaped proxy entry into the host-scoped
|
||||
* {@link usePreviewExposureStore}.
|
||||
*
|
||||
* For canonical `$$`-prefixed preview names the source widget may be lazily
|
||||
* created at first execution; we treat the exposure as metadata-only and do
|
||||
* not require the concrete widget to be present yet. For non-`$$` previews
|
||||
* (e.g. `videopreview`) the widget must already exist on the source node.
|
||||
*/
|
||||
export function migratePreviewExposure(
|
||||
args: MigratePreviewExposureArgs
|
||||
): MigratePreviewExposureResult {
|
||||
const { hostNode, entry, store } = args
|
||||
const { plan } = entry
|
||||
|
||||
if (plan.kind !== 'previewExposure') {
|
||||
throw new Error(`migratePreviewExposure: invalid plan kind ${plan.kind}`)
|
||||
}
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(
|
||||
entry.normalized.sourceNodeId
|
||||
)
|
||||
if (!sourceNode) {
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$')
|
||||
if (!isCanonicalPseudo) {
|
||||
const widget = sourceNode.widgets?.find(
|
||||
(w) => w.name === plan.sourcePreviewName
|
||||
)
|
||||
if (!widget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
}
|
||||
|
||||
const hostNodeLocator = String(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
|
||||
})
|
||||
|
||||
return { ok: true, previewName: added.name }
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
|
||||
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
setSubgraphMigrationFlushHook(undefined)
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
describe(flushProxyWidgetMigration, () => {
|
||||
it('returns an empty result when no proxyWidgets are present', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result).toEqual({
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates a preview-shaped entry into the PreviewExposureStore', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result.previewMigrated).toBe(1)
|
||||
expect(result.quarantined).toBe(0)
|
||||
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
expect(exposures).toHaveLength(1)
|
||||
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
|
||||
})
|
||||
|
||||
it('quarantines entries whose source node has disappeared', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
const result = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(result.quarantined).toBe(1)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'seed'],
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('counts already-linked entries as repaired and applies the host value', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
let widgetValue: TWidgetValue = 0
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
|
||||
const result = flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(result.repaired).toBe(1)
|
||||
expect(result.quarantined).toBe(0)
|
||||
expect(widgetValue).toBe(99)
|
||||
})
|
||||
|
||||
it('clears properties.proxyWidgets after a successful flush', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('runs through LGraph.configure when the flush hook is wired', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const serialized = host.rootGraph.serialize()
|
||||
wireProxyWidgetMigrationFlush()
|
||||
const reloadedGraph = new LGraph()
|
||||
const subgraph = host.subgraph
|
||||
const instanceData = host.serialize()
|
||||
LiteGraph.registerNodeType(
|
||||
subgraph.id,
|
||||
class TestSubgraphNode extends SubgraphNode {
|
||||
constructor() {
|
||||
super(reloadedGraph, subgraph, instanceData)
|
||||
}
|
||||
}
|
||||
)
|
||||
try {
|
||||
reloadedGraph.configure(serialized)
|
||||
} finally {
|
||||
LiteGraph.unregisterNodeType(subgraph.id)
|
||||
}
|
||||
|
||||
const reloadedHost = reloadedGraph.getNodeById(host.id)
|
||||
expect(reloadedHost?.properties.proxyWidgets).toBeUndefined()
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('re-running flush over a fully migrated host produces no further mutations', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const first = flushProxyWidgetMigration({ hostNode: host })
|
||||
expect(first.previewMigrated).toBe(1)
|
||||
|
||||
const exposuresAfterFirst = usePreviewExposureStore()
|
||||
.getExposures(host.rootGraph.id, String(host.id))
|
||||
.map((e) => ({ ...e }))
|
||||
|
||||
const second = flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(second).toEqual({
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
})
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
).toEqual(exposuresAfterFirst)
|
||||
})
|
||||
|
||||
it('re-running flush over a quarantined host does not duplicate quarantine entries', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
const firstQuarantine = readHostQuarantine(host)
|
||||
expect(firstQuarantine).toHaveLength(1)
|
||||
|
||||
// Reseed proxyWidgets to simulate a stale legacy reload of the same
|
||||
// unresolved entry; flush must still produce no duplicates.
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
})
|
||||
})
|
||||
166
src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts
Normal file
166
src/core/graph/subgraph/migration/proxyWidgetMigrationFlush.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
|
||||
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
|
||||
import {
|
||||
appendHostQuarantine,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
|
||||
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import type { ProxyWidgetErrorQuarantineEntry } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
interface FlushProxyWidgetMigrationArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** widgets_values from the host node at parse time. May be sparse. */
|
||||
hostWidgetValues?: readonly unknown[]
|
||||
}
|
||||
|
||||
interface FlushProxyWidgetMigrationResult {
|
||||
repaired: number
|
||||
primitiveRepaired: number
|
||||
previewMigrated: number
|
||||
quarantined: number
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: FlushProxyWidgetMigrationResult = {
|
||||
repaired: 0,
|
||||
primitiveRepaired: 0,
|
||||
previewMigrated: 0,
|
||||
quarantined: 0
|
||||
}
|
||||
|
||||
function toLegacyTuple(
|
||||
source: LegacyProxyEntrySource
|
||||
): SerializedProxyWidgetTuple {
|
||||
return source.disambiguatingSourceNodeId
|
||||
? [
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName,
|
||||
source.disambiguatingSourceNodeId
|
||||
]
|
||||
: [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function unwrapHostValue(
|
||||
hostValue: PendingMigrationEntry['hostValue']
|
||||
): TWidgetValue | undefined {
|
||||
return hostValue === HOST_VALUE_HOLE ? undefined : (hostValue as TWidgetValue)
|
||||
}
|
||||
|
||||
function quarantineFor(
|
||||
entry: PendingMigrationEntry,
|
||||
reason: ProxyWidgetErrorQuarantineEntry['reason']
|
||||
): ProxyWidgetErrorQuarantineEntry {
|
||||
return makeQuarantineEntry({
|
||||
originalEntry: toLegacyTuple(entry.normalized),
|
||||
reason,
|
||||
hostValue: unwrapHostValue(entry.hostValue)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-ratchet a host SubgraphNode's legacy `properties.proxyWidgets` into
|
||||
* canonical representations:
|
||||
*
|
||||
* - value-widget entries → linked SubgraphInput via {@link repairValueWidget};
|
||||
* - primitive-fanout cohorts → one SubgraphInput per primitive via
|
||||
* {@link repairPrimitiveFanout};
|
||||
* - preview entries → host-scoped exposure via {@link migratePreviewExposure};
|
||||
* - unrepairable / quarantine plans → appended to
|
||||
* `properties.proxyWidgetErrorQuarantine`.
|
||||
*
|
||||
* Idempotent: re-running flush over an already-migrated host produces no
|
||||
* mutations and no duplicates because (a) the planner classifies migrated
|
||||
* entries as `alreadyLinked` (a no-op apply), (b) preview/quarantine helpers
|
||||
* dedup, and (c) the legacy `properties.proxyWidgets` is removed once flush
|
||||
* succeeds so subsequent calls return early.
|
||||
*/
|
||||
export function flushProxyWidgetMigration(
|
||||
args: FlushProxyWidgetMigrationArgs
|
||||
): FlushProxyWidgetMigrationResult {
|
||||
const { hostNode, hostWidgetValues } = args
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode, hostWidgetValues })
|
||||
if (plan.entries.length === 0) return EMPTY_RESULT
|
||||
|
||||
const previewStore = usePreviewExposureStore()
|
||||
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
|
||||
const result: FlushProxyWidgetMigrationResult = { ...EMPTY_RESULT }
|
||||
|
||||
// Group primitive-bypass entries per primitive node. Cohort flushed
|
||||
// all-or-nothing through repairPrimitiveFanout.
|
||||
const primitiveCohorts = new Map<NodeId, PendingMigrationEntry[]>()
|
||||
|
||||
for (const entry of plan.entries) {
|
||||
const { plan: planEntry } = entry
|
||||
|
||||
if (planEntry.kind === 'primitiveBypass') {
|
||||
const cohort = primitiveCohorts.get(planEntry.primitiveNodeId) ?? []
|
||||
cohort.push(entry)
|
||||
primitiveCohorts.set(planEntry.primitiveNodeId, cohort)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
planEntry.kind === 'alreadyLinked' ||
|
||||
planEntry.kind === 'createSubgraphInput'
|
||||
) {
|
||||
const repair = repairValueWidget({ hostNode, entry })
|
||||
if (repair.ok) {
|
||||
result.repaired += 1
|
||||
} else {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (planEntry.kind === 'previewExposure') {
|
||||
const repair = migratePreviewExposure({
|
||||
hostNode,
|
||||
entry,
|
||||
store: previewStore
|
||||
})
|
||||
if (repair.ok) {
|
||||
result.previewMigrated += 1
|
||||
} else {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (planEntry.kind === 'quarantine') {
|
||||
quarantineToAppend.push(quarantineFor(entry, planEntry.reason))
|
||||
}
|
||||
}
|
||||
|
||||
for (const cohort of primitiveCohorts.values()) {
|
||||
const repair = repairPrimitiveFanout({ hostNode, cohort })
|
||||
if (repair.ok) {
|
||||
result.primitiveRepaired += 1
|
||||
} else {
|
||||
for (const entry of cohort) {
|
||||
quarantineToAppend.push(quarantineFor(entry, repair.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quarantineToAppend.length > 0) {
|
||||
appendHostQuarantine(hostNode, quarantineToAppend)
|
||||
result.quarantined = quarantineToAppend.length
|
||||
}
|
||||
|
||||
// Idempotency anchor: once entries have been processed, drop the legacy
|
||||
// payload so subsequent loads/configures take the no-op short-circuit.
|
||||
// Canonical state now lives on linked SubgraphInputs, the
|
||||
// PreviewExposureStore, and properties.proxyWidgetErrorQuarantine.
|
||||
delete hostNode.properties.proxyWidgets
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
/**
|
||||
* Sentinel marking a sparse hole in a `widgets_values` array. Distinct from
|
||||
* `undefined` so that an explicitly-stored `undefined` host value can still be
|
||||
* represented when needed.
|
||||
*/
|
||||
export const HOST_VALUE_HOLE = Symbol('proxyWidgetMigration.hostValueHole')
|
||||
export type HostValueHole = typeof HOST_VALUE_HOLE
|
||||
|
||||
export type HostValue = TWidgetValue | HostValueHole
|
||||
|
||||
export type ProxyEntryClassification =
|
||||
| 'value'
|
||||
| 'preview'
|
||||
| 'primitiveFanout'
|
||||
| 'unknown'
|
||||
|
||||
export interface PrimitiveBypassTargetRef {
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
export type MigrationPlan =
|
||||
| { kind: 'alreadyLinked'; subgraphInputName: string }
|
||||
| { kind: 'createSubgraphInput'; sourceWidgetName: string }
|
||||
| {
|
||||
kind: 'primitiveBypass'
|
||||
primitiveNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
targets: readonly PrimitiveBypassTargetRef[]
|
||||
}
|
||||
| { kind: 'previewExposure'; sourcePreviewName: string }
|
||||
| { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason }
|
||||
|
||||
/**
|
||||
* One pending migration entry produced by the planner.
|
||||
*
|
||||
* @remarks
|
||||
* This is the input to the flush step. The planner does not mutate the graph;
|
||||
* it walks legacy `properties.proxyWidgets` and `widgets_values`, classifies
|
||||
* each entry, and emits a {@link PendingMigrationEntry} describing what the
|
||||
* flush should do. Flush re-validates against the current graph before
|
||||
* applying mutations.
|
||||
*/
|
||||
export interface PendingMigrationEntry {
|
||||
normalized: LegacyProxyEntrySource
|
||||
legacyOrderIndex: number
|
||||
hostValue: HostValue
|
||||
plan: MigrationPlan
|
||||
}
|
||||
|
||||
/**
|
||||
* The full plan the planner returns for a single host SubgraphNode.
|
||||
*
|
||||
* Entries are ordered by `legacyOrderIndex` ascending. Idempotency: re-running
|
||||
* the planner over a host whose canonical state already represents an entry
|
||||
* yields a `'alreadyLinked'`/`'previewExposure'` plan that the flush step
|
||||
* treats as a no-op.
|
||||
*/
|
||||
export interface ProxyWidgetMigrationPlan {
|
||||
entries: readonly PendingMigrationEntry[]
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
entries: readonly PendingMigrationEntry[],
|
||||
index: number
|
||||
): PendingMigrationEntry {
|
||||
const entry = entries.find((e) => e.legacyOrderIndex === index)
|
||||
if (!entry) throw new Error(`Expected entry at legacyOrderIndex ${index}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
describe(planProxyWidgetMigration, () => {
|
||||
it('returns an empty plan when properties.proxyWidgets is missing', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('tolerates a malformed proxyWidgets JSON string and returns empty', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = '{not json}'
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('emits classified entries for a mixed value+preview cohort, preserving order', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), 'seed'],
|
||||
[String(innerNode.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const plan = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(plan.entries).toHaveLength(2)
|
||||
const valueEntry = findEntry(plan.entries, 0)
|
||||
expect(valueEntry.plan).toEqual({
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(valueEntry.hostValue).toBe(99)
|
||||
|
||||
const previewEntry = findEntry(plan.entries, 1)
|
||||
expect(previewEntry.plan).toEqual({
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
expect(previewEntry.hostValue).toBe(HOST_VALUE_HOLE)
|
||||
})
|
||||
|
||||
it('preserves sparse holes in widgets_values when they are missing', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'a', 0, () => {})
|
||||
innerNode.addWidget('number', 'b', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerNode.id), 'a'],
|
||||
[String(innerNode.id), 'b']
|
||||
]
|
||||
|
||||
const sparse: unknown[] = []
|
||||
sparse[1] = 'second-value'
|
||||
|
||||
const plan = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(findEntry(plan.entries, 0).hostValue).toBe(HOST_VALUE_HOLE)
|
||||
expect(findEntry(plan.entries, 1).hostValue).toBe('second-value')
|
||||
})
|
||||
|
||||
it('emits a primitiveBypass plan per cohort entry pointing at the same primitive', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const targetA = new LGraphNode('TargetA')
|
||||
targetA.addInput('value', 'INT')
|
||||
targetA.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetA)
|
||||
|
||||
const targetB = new LGraphNode('TargetB')
|
||||
targetB.addInput('value', 'INT')
|
||||
targetB.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(targetB)
|
||||
|
||||
primitive.connect(0, targetA, 0)
|
||||
primitive.connect(0, targetB, 0)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toHaveLength(2)
|
||||
for (const entry of plan.entries) {
|
||||
expect(entry.plan.kind).toBe('primitiveBypass')
|
||||
if (entry.plan.kind !== 'primitiveBypass') continue
|
||||
expect(entry.plan.primitiveNodeId).toBe(primitive.id)
|
||||
expect(entry.plan.targets).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent: re-running on a host whose entries are already linked yields alreadyLinked plans', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
|
||||
const firstPass = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [42]
|
||||
})
|
||||
|
||||
expect(findEntry(firstPass.entries, 0).plan).toEqual({
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
// Simulate the flush step linking the input.
|
||||
const inputSlot = host.addInput('seed', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
const secondPass = planProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [42]
|
||||
})
|
||||
|
||||
expect(secondPass.entries).toHaveLength(1)
|
||||
expect(findEntry(secondPass.entries, 0).plan).toEqual({
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('quarantines entries pointing at missing source nodes', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
const plan = planProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(plan.entries).toHaveLength(1)
|
||||
expect(findEntry(plan.entries, 0).plan).toEqual({
|
||||
kind: 'quarantine',
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
|
||||
import type {
|
||||
HostValue,
|
||||
PendingMigrationEntry,
|
||||
ProxyWidgetMigrationPlan
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
|
||||
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
interface PlanProxyWidgetMigrationArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** widgets_values from the host node at parse time. May be sparse. */
|
||||
hostWidgetValues?: readonly unknown[]
|
||||
}
|
||||
|
||||
function pickHostValue(
|
||||
hostWidgetValues: readonly unknown[] | undefined,
|
||||
index: number
|
||||
): HostValue {
|
||||
if (!hostWidgetValues) return HOST_VALUE_HOLE
|
||||
if (index < 0 || index >= hostWidgetValues.length) return HOST_VALUE_HOLE
|
||||
if (!Object.prototype.hasOwnProperty.call(hostWidgetValues, index)) {
|
||||
return HOST_VALUE_HOLE
|
||||
}
|
||||
return hostWidgetValues[index] as TWidgetValue
|
||||
}
|
||||
|
||||
export function planProxyWidgetMigration(
|
||||
args: PlanProxyWidgetMigrationArgs
|
||||
): ProxyWidgetMigrationPlan {
|
||||
const { hostNode, hostWidgetValues } = args
|
||||
|
||||
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
|
||||
if (tuples.length === 0) return { entries: [] }
|
||||
|
||||
const normalized: LegacyProxyEntrySource[] = tuples.map(
|
||||
([sourceNodeId, sourceWidgetName, disambiguator]) =>
|
||||
normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguator
|
||||
)
|
||||
)
|
||||
|
||||
const entries: PendingMigrationEntry[] = normalized.map(
|
||||
(entry, legacyOrderIndex) => {
|
||||
const { plan } = classifyProxyEntry({
|
||||
hostNode,
|
||||
normalized: entry,
|
||||
cohort: normalized
|
||||
})
|
||||
return {
|
||||
normalized: entry,
|
||||
legacyOrderIndex,
|
||||
hostValue: pickHostValue(hostWidgetValues, legacyOrderIndex),
|
||||
plan
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
entries.sort((a, b) => a.legacyOrderIndex - b.legacyOrderIndex)
|
||||
|
||||
return { entries }
|
||||
}
|
||||
149
src/core/graph/subgraph/migration/quarantineEntry.test.ts
Normal file
149
src/core/graph/subgraph/migration/quarantineEntry.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendHostQuarantine,
|
||||
clearHostQuarantine,
|
||||
makeQuarantineEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
describe(makeQuarantineEntry, () => {
|
||||
it('builds an entry with attemptedAtVersion pinned to 1', () => {
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode'
|
||||
})
|
||||
|
||||
expect(entry).toEqual({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('includes hostValue when provided', () => {
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 42
|
||||
})
|
||||
|
||||
expect(entry.hostValue).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host quarantine helpers', () => {
|
||||
it('returns an empty array for an unconfigured host', () => {
|
||||
const host = buildHost()
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trips entries via append + read', () => {
|
||||
const host = buildHost()
|
||||
const entry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget',
|
||||
hostValue: 'preserved'
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [entry])
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([entry])
|
||||
})
|
||||
|
||||
it('deduplicates entries with identical originalEntry tuples', () => {
|
||||
const host = buildHost()
|
||||
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
|
||||
const first = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'missingSourceWidget',
|
||||
hostValue: 1
|
||||
})
|
||||
const duplicate = makeQuarantineEntry({
|
||||
originalEntry: tuple,
|
||||
reason: 'unlinkedSourceWidget',
|
||||
hostValue: 2
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [first])
|
||||
appendHostQuarantine(host, [duplicate])
|
||||
|
||||
const stored = readHostQuarantine(host)
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0]).toEqual(first)
|
||||
})
|
||||
|
||||
it('keeps entries that differ by disambiguator in the originalEntry tuple', () => {
|
||||
const host = buildHost()
|
||||
const baseEntry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
const disambiguatedEntry = makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed', 'inner-leaf'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
|
||||
appendHostQuarantine(host, [baseEntry, disambiguatedEntry])
|
||||
|
||||
expect(readHostQuarantine(host)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clearHostQuarantine removes the property entirely', () => {
|
||||
const host = buildHost()
|
||||
appendHostQuarantine(host, [
|
||||
makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
])
|
||||
|
||||
clearHostQuarantine(host)
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(readHostQuarantine(host)).toEqual([])
|
||||
})
|
||||
|
||||
it('appendHostQuarantine is a no-op when given an empty list', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendHostQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
67
src/core/graph/subgraph/migration/quarantineEntry.ts
Normal file
67
src/core/graph/subgraph/migration/quarantineEntry.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
ProxyWidgetErrorQuarantineEntry,
|
||||
ProxyWidgetQuarantineReason
|
||||
} from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
|
||||
const QUARANTINE_VERSION = 1
|
||||
|
||||
interface MakeQuarantineEntryArgs {
|
||||
originalEntry: SerializedProxyWidgetTuple
|
||||
reason: ProxyWidgetQuarantineReason
|
||||
hostValue?: TWidgetValue
|
||||
}
|
||||
|
||||
export function readHostQuarantine(
|
||||
hostNode: SubgraphNode
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
return parseProxyWidgetErrorQuarantine(
|
||||
hostNode.properties[QUARANTINE_PROPERTY]
|
||||
)
|
||||
}
|
||||
|
||||
export function makeQuarantineEntry(
|
||||
args: MakeQuarantineEntryArgs
|
||||
): ProxyWidgetErrorQuarantineEntry {
|
||||
const entry: ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: args.originalEntry,
|
||||
reason: args.reason,
|
||||
attemptedAtVersion: QUARANTINE_VERSION
|
||||
}
|
||||
if (args.hostValue !== undefined) {
|
||||
entry.hostValue = args.hostValue
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
export function appendHostQuarantine(
|
||||
hostNode: SubgraphNode,
|
||||
entries: readonly ProxyWidgetErrorQuarantineEntry[]
|
||||
): void {
|
||||
if (entries.length === 0) return
|
||||
|
||||
const existing = readHostQuarantine(hostNode)
|
||||
const merged = [...existing]
|
||||
for (const candidate of entries) {
|
||||
const isDuplicate = merged.some((existingEntry) =>
|
||||
isEqual(existingEntry.originalEntry, candidate.originalEntry)
|
||||
)
|
||||
if (!isDuplicate) merged.push(candidate)
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
delete hostNode.properties[QUARANTINE_PROPERTY]
|
||||
return
|
||||
}
|
||||
hostNode.properties[QUARANTINE_PROPERTY] = merged
|
||||
}
|
||||
|
||||
export function clearHostQuarantine(hostNode: SubgraphNode): void {
|
||||
delete hostNode.properties[QUARANTINE_PROPERTY]
|
||||
}
|
||||
188
src/core/graph/subgraph/migration/repairPrimitiveFanout.test.ts
Normal file
188
src/core/graph/subgraph/migration/repairPrimitiveFanout.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
interface PrimitiveScenario {
|
||||
host: SubgraphNode
|
||||
primitive: LGraphNode
|
||||
targets: LGraphNode[]
|
||||
}
|
||||
|
||||
function buildPrimitiveScenario(targetCount: number): PrimitiveScenario {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
primitive.addWidget('number', 'value', 42, () => {})
|
||||
subgraph.add(primitive)
|
||||
|
||||
const targets: LGraphNode[] = []
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const target = new LGraphNode(`Target${i}`)
|
||||
const slot = target.addInput('value', 'INT')
|
||||
slot.widget = { name: 'value' }
|
||||
target.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(target)
|
||||
primitive.connect(0, target, 0)
|
||||
targets.push(target)
|
||||
}
|
||||
|
||||
return { host, primitive, targets }
|
||||
}
|
||||
|
||||
function buildCohort(
|
||||
primitive: LGraphNode,
|
||||
targets: readonly LGraphNode[],
|
||||
options: {
|
||||
hostValuePerEntry?: readonly PendingMigrationEntry['hostValue'][]
|
||||
} = {}
|
||||
): PendingMigrationEntry[] {
|
||||
return targets.map((target, index) => ({
|
||||
normalized: {
|
||||
sourceNodeId: String(primitive.id),
|
||||
sourceWidgetName: 'value',
|
||||
// Distinguish entries by the downstream target so coalesce keeps each.
|
||||
disambiguatingSourceNodeId: String(target.id)
|
||||
},
|
||||
legacyOrderIndex: index,
|
||||
hostValue: Object.prototype.hasOwnProperty.call(
|
||||
options.hostValuePerEntry ?? [],
|
||||
index
|
||||
)
|
||||
? options.hostValuePerEntry![index]
|
||||
: HOST_VALUE_HOLE,
|
||||
plan: {
|
||||
kind: 'primitiveBypass',
|
||||
primitiveNodeId: primitive.id,
|
||||
sourceWidgetName: 'value',
|
||||
targets: targets.map((t) => ({
|
||||
targetNodeId: t.id,
|
||||
targetSlot: 0
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
describe(repairPrimitiveFanout, () => {
|
||||
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(3)
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
|
||||
const subgraphInputCountBefore = host.subgraph.inputs.length
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.reconnectCount).toBe(3)
|
||||
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
|
||||
// After mutation each target's slot should no longer be linked to the primitive.
|
||||
for (const target of targets) {
|
||||
const slot = target.inputs[0]
|
||||
expect(slot.link).not.toBeNull()
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('host value (first by legacyOrderIndex) wins over primitive widget value', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(2)
|
||||
const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')!
|
||||
primitiveWidget.value = 11
|
||||
|
||||
const cohort = buildCohort(primitive, targets, {
|
||||
hostValuePerEntry: [123, 456]
|
||||
})
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const created = host.subgraph.inputs.find(
|
||||
(i) => i.name === result.subgraphInputName
|
||||
)
|
||||
expect(created?._widget?.value).toBe(123)
|
||||
})
|
||||
|
||||
it('preserves an explicit undefined host value instead of falling back to primitive value', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(2)
|
||||
const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')!
|
||||
primitiveWidget.value = 11
|
||||
|
||||
const cohort = buildCohort(primitive, targets, {
|
||||
hostValuePerEntry: [undefined, 456]
|
||||
})
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const created = host.subgraph.inputs.find(
|
||||
(i) => i.name === result.subgraphInputName
|
||||
)
|
||||
expect(created?._widget?.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('coalesces duplicate entries that share normalized source', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(2)
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
|
||||
// Append an exact duplicate of the first cohort entry.
|
||||
cohort.push({ ...cohort[0], legacyOrderIndex: 99 })
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
// 2 unique targets → 2 reconnects regardless of duplicate cohort entries.
|
||||
expect(result.reconnectCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns primitiveBypassFailed when a target slot type is incompatible', () => {
|
||||
const { host, primitive, targets } = buildPrimitiveScenario(1)
|
||||
// Replace the existing target slot type with something incompatible.
|
||||
targets[0].inputs[0].type = 'STRING'
|
||||
|
||||
const cohort = buildCohort(primitive, targets)
|
||||
const subgraphInputCountBefore = host.subgraph.inputs.length
|
||||
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
|
||||
// No new SubgraphInput created.
|
||||
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore)
|
||||
})
|
||||
|
||||
it('returns primitiveBypassFailed for an empty cohort', () => {
|
||||
const { host } = buildPrimitiveScenario(0)
|
||||
const result = repairPrimitiveFanout({ hostNode: host, cohort: [] })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
|
||||
})
|
||||
})
|
||||
299
src/core/graph/subgraph/migration/repairPrimitiveFanout.ts
Normal file
299
src/core/graph/subgraph/migration/repairPrimitiveFanout.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import type {
|
||||
PendingMigrationEntry,
|
||||
HostValue,
|
||||
PrimitiveBypassTargetRef
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type RepairPrimitiveFanoutResult =
|
||||
| { ok: true; subgraphInputName: string; reconnectCount: number }
|
||||
| { ok: false; reason: 'primitiveBypassFailed' }
|
||||
|
||||
interface RepairPrimitiveFanoutArgs {
|
||||
hostNode: SubgraphNode
|
||||
/** All cohort entries whose plan is `primitiveBypass` for this primitive. */
|
||||
cohort: readonly PendingMigrationEntry[]
|
||||
}
|
||||
|
||||
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
|
||||
const FAILED: RepairPrimitiveFanoutResult = {
|
||||
ok: false,
|
||||
reason: 'primitiveBypassFailed'
|
||||
}
|
||||
|
||||
interface SnapshotLink {
|
||||
primitiveSlot: number
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
function fail(message: string, context?: unknown): RepairPrimitiveFanoutResult {
|
||||
console.warn(`[repairPrimitiveFanout] ${message}`, context)
|
||||
return FAILED
|
||||
}
|
||||
|
||||
interface CohortValidationOk {
|
||||
ok: true
|
||||
primitiveNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
uniqueEntries: readonly PendingMigrationEntry[]
|
||||
}
|
||||
|
||||
function validateCohort(
|
||||
cohort: readonly PendingMigrationEntry[]
|
||||
): CohortValidationOk | { ok: false } {
|
||||
if (cohort.length === 0) return { ok: false }
|
||||
|
||||
const first = cohort[0]
|
||||
if (first.plan.kind !== 'primitiveBypass') return { ok: false }
|
||||
|
||||
const primitiveNodeId = first.plan.primitiveNodeId
|
||||
const sourceWidgetName = first.plan.sourceWidgetName
|
||||
|
||||
for (const entry of cohort) {
|
||||
if (entry.plan.kind !== 'primitiveBypass') return { ok: false }
|
||||
if (entry.plan.primitiveNodeId !== primitiveNodeId) return { ok: false }
|
||||
if (entry.plan.sourceWidgetName !== sourceWidgetName) return { ok: false }
|
||||
}
|
||||
|
||||
// Coalesce exact duplicates by `normalized`.
|
||||
const uniqueEntries: PendingMigrationEntry[] = []
|
||||
for (const entry of cohort) {
|
||||
if (
|
||||
!uniqueEntries.some((kept) => isEqual(kept.normalized, entry.normalized))
|
||||
) {
|
||||
uniqueEntries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
|
||||
}
|
||||
|
||||
function pickBaseName(
|
||||
primitiveNode: LGraphNode,
|
||||
sourceWidgetName: string
|
||||
): string {
|
||||
// Heuristic: a user-renamed PrimitiveNode title differs from its default
|
||||
// 'PrimitiveNode' label. When unrenamed, fall back to the source widget name.
|
||||
if (primitiveNode.title && primitiveNode.title !== PRIMITIVE_NODE_TYPE) {
|
||||
return primitiveNode.title
|
||||
}
|
||||
return sourceWidgetName
|
||||
}
|
||||
|
||||
function collectTargets(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] | undefined {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const targets: PrimitiveBypassTargetRef[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) return undefined
|
||||
targets.push({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
function snapshotLinksForRollback(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): SnapshotLink[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const snapshot: SnapshotLink[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) continue
|
||||
snapshot.push({
|
||||
primitiveSlot: link.origin_slot,
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
})
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function rollback(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode,
|
||||
newSubgraphInput: SubgraphInput | undefined,
|
||||
snapshot: readonly SnapshotLink[]
|
||||
): void {
|
||||
if (newSubgraphInput) {
|
||||
try {
|
||||
hostNode.subgraph.removeInput(newSubgraphInput)
|
||||
} catch (e) {
|
||||
console.warn('[repairPrimitiveFanout] rollback removeInput failed', e)
|
||||
}
|
||||
}
|
||||
for (const link of snapshot) {
|
||||
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
|
||||
if (!targetNode) continue
|
||||
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
|
||||
}
|
||||
}
|
||||
|
||||
function pickHostValue(
|
||||
uniqueEntries: readonly PendingMigrationEntry[]
|
||||
): HostValue {
|
||||
const ordered = [...uniqueEntries].sort(
|
||||
(a, b) => a.legacyOrderIndex - b.legacyOrderIndex
|
||||
)
|
||||
for (const entry of ordered) {
|
||||
if (entry.hostValue !== HOST_VALUE_HOLE) return entry.hostValue
|
||||
}
|
||||
return HOST_VALUE_HOLE
|
||||
}
|
||||
|
||||
function applyHostValue(widget: IBaseWidget, hostValue: HostValue): void {
|
||||
if (hostValue === HOST_VALUE_HOLE) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(hostValue)
|
||||
return
|
||||
}
|
||||
widget.value = hostValue as TWidgetValue
|
||||
}
|
||||
|
||||
/**
|
||||
* All-or-quarantine repair of one primitive's fan-out into a single
|
||||
* SubgraphInput.
|
||||
*
|
||||
* Each call repairs ONE primitive node and the cohort of legacy entries that
|
||||
* pointed at it. On any failure during validation or mutation, the helper
|
||||
* rolls back any partial changes and returns
|
||||
* `{ ok: false, reason: 'primitiveBypassFailed' }` so the caller can
|
||||
* quarantine all cohort entries.
|
||||
*/
|
||||
export function repairPrimitiveFanout(
|
||||
args: RepairPrimitiveFanoutArgs
|
||||
): RepairPrimitiveFanoutResult {
|
||||
const { hostNode, cohort } = args
|
||||
|
||||
const validated = validateCohort(cohort)
|
||||
if (!validated.ok) return fail('cohort validation failed', { cohort })
|
||||
|
||||
const subgraph = hostNode.subgraph
|
||||
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
|
||||
if (!primitiveNode) {
|
||||
return fail('primitive node missing', {
|
||||
primitiveNodeId: validated.primitiveNodeId
|
||||
})
|
||||
}
|
||||
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
|
||||
return fail('node is not a PrimitiveNode', {
|
||||
primitiveNodeId: validated.primitiveNodeId,
|
||||
type: primitiveNode.type
|
||||
})
|
||||
}
|
||||
|
||||
const targets = collectTargets(hostNode, primitiveNode)
|
||||
if (!targets || targets.length === 0) {
|
||||
return fail('no targets to reconnect', {
|
||||
primitiveNodeId: validated.primitiveNodeId
|
||||
})
|
||||
}
|
||||
|
||||
const primitiveOutput = primitiveNode.outputs?.[0]
|
||||
if (!primitiveOutput) return fail('primitive has no output')
|
||||
const primitiveOutputType = String(primitiveOutput.type ?? '*')
|
||||
|
||||
// Pre-validate compatibility of every target before mutating.
|
||||
for (const target of targets) {
|
||||
const targetNode = subgraph.getNodeById(target.targetNodeId)
|
||||
if (!targetNode) return fail('target node missing', target)
|
||||
const targetSlot = targetNode.inputs?.[target.targetSlot]
|
||||
if (!targetSlot) return fail('target slot missing', target)
|
||||
const targetType = String(targetSlot.type ?? '*')
|
||||
if (
|
||||
targetType !== primitiveOutputType &&
|
||||
targetType !== '*' &&
|
||||
primitiveOutputType !== '*'
|
||||
) {
|
||||
return fail('target slot type incompatible', {
|
||||
target,
|
||||
targetType,
|
||||
primitiveOutputType
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = pickBaseName(primitiveNode, validated.sourceWidgetName)
|
||||
const existingNames = subgraph.inputs.map((input) => input.name)
|
||||
const uniqueName = nextUniqueName(baseName, existingNames)
|
||||
|
||||
const snapshot = snapshotLinksForRollback(hostNode, primitiveNode)
|
||||
|
||||
let newSubgraphInput: SubgraphInput | undefined
|
||||
try {
|
||||
newSubgraphInput = subgraph.addInput(uniqueName, primitiveOutputType)
|
||||
|
||||
// Disconnect every former primitive→target link.
|
||||
for (const snap of snapshot) {
|
||||
const targetNode = subgraph.getNodeById(snap.targetNodeId)
|
||||
if (!targetNode)
|
||||
throw new Error(
|
||||
`target node ${snap.targetNodeId} disappeared mid-mutation`
|
||||
)
|
||||
targetNode.disconnectInput(snap.targetSlot, false)
|
||||
}
|
||||
|
||||
// Reconnect each target slot from the new SubgraphInput, in target order.
|
||||
for (const target of targets) {
|
||||
const targetNode = subgraph.getNodeById(target.targetNodeId)
|
||||
if (!targetNode)
|
||||
throw new Error(`target node ${target.targetNodeId} disappeared`)
|
||||
const targetSlot = targetNode.inputs?.[target.targetSlot]
|
||||
if (!targetSlot)
|
||||
throw new Error(`target slot ${target.targetSlot} disappeared`)
|
||||
const link = newSubgraphInput.connect(targetSlot, targetNode)
|
||||
if (!link) {
|
||||
throw new Error('SubgraphInput.connect returned no link')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rollback(hostNode, primitiveNode, newSubgraphInput, snapshot)
|
||||
return fail('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply value: prefer first-by-legacyOrderIndex non-hole host value;
|
||||
// otherwise seed from the primitive's source widget value if present.
|
||||
const hostValue = pickHostValue(validated.uniqueEntries)
|
||||
if (hostValue !== HOST_VALUE_HOLE) {
|
||||
if (newSubgraphInput._widget)
|
||||
applyHostValue(newSubgraphInput._widget, hostValue)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined && newSubgraphInput._widget) {
|
||||
newSubgraphInput._widget.value = primitiveValue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subgraphInputName: newSubgraphInput.name,
|
||||
reconnectCount: targets.length
|
||||
}
|
||||
}
|
||||
330
src/core/graph/subgraph/migration/repairValueWidget.test.ts
Normal file
330
src/core/graph/subgraph/migration/repairValueWidget.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const graph = hostNode.graph!
|
||||
graph.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function buildEntry(args: {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
plan: PendingMigrationEntry['plan']
|
||||
hostValue?: PendingMigrationEntry['hostValue']
|
||||
}): PendingMigrationEntry {
|
||||
return {
|
||||
normalized: {
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
...(args.disambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: args.disambiguatingSourceNodeId
|
||||
})
|
||||
},
|
||||
legacyOrderIndex: 0,
|
||||
hostValue: args.hostValue ?? HOST_VALUE_HOLE,
|
||||
plan: args.plan
|
||||
}
|
||||
}
|
||||
|
||||
describe(repairValueWidget, () => {
|
||||
describe('alreadyLinked plan', () => {
|
||||
it('hydrates real promoted widget host state without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
const slot = innerNode.addInput('seed', 'INT')
|
||||
const innerWidget = innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
subgraph.add(innerNode)
|
||||
subgraph.inputNode.slots[0].connect(slot, innerNode)
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed' },
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'seed' })
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('applies host value to the linked input widget (host wins over interior)', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 7
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' },
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
|
||||
expect(inputSlot._widget?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('leaves widget value unchanged when hostValue is HOST_VALUE_HOLE', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputSlot = host.addInput('seed_link', '*')
|
||||
inputSlot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 7
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
|
||||
expect(inputSlot._widget?.value).toBe(7)
|
||||
})
|
||||
|
||||
it('routes by subgraphInputName, ignoring legacy disambiguator metadata', () => {
|
||||
// ADR 0009: canonical PromotedWidgetView no longer carries a
|
||||
// `disambiguatingSourceNodeId`. Repair routes the host value to the
|
||||
// input named by `subgraphInputName`; any disambiguator carried on the
|
||||
// legacy entry is metadata only and does not affect the canonical
|
||||
// match.
|
||||
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',
|
||||
value: 1
|
||||
})
|
||||
const secondInput = host.addInput('second_seed', '*')
|
||||
secondInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
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('does not apply host value when already-linked inputs are ambiguous', () => {
|
||||
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',
|
||||
value: 1
|
||||
})
|
||||
const secondInput = host.addInput('second_seed', '*')
|
||||
secondInput._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: 'seed',
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
value: 2
|
||||
})
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: {
|
||||
kind: 'alreadyLinked',
|
||||
subgraphInputName: undefined as never
|
||||
},
|
||||
hostValue: 99
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'ambiguousSubgraphInput' })
|
||||
expect(firstInput._widget?.value).toBe(1)
|
||||
expect(secondInput._widget?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('returns missingSubgraphInput when the linked SubgraphInput is gone', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSubgraphInput' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSubgraphInput plan', () => {
|
||||
it('creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
const slot = innerNode.addInput('seed', 'INT')
|
||||
slot.widget = { name: 'seed' }
|
||||
innerNode.addWidget('number', 'seed', 0, () => {})
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
if (result.ok) {
|
||||
expect(result.subgraphInputName).toBe(created?.name)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns missingSourceNode when the source node is absent', () => {
|
||||
const host = buildHost()
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: '999',
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
|
||||
})
|
||||
|
||||
it('returns missingSourceWidget when the widget is absent on the source node', () => {
|
||||
const host = buildHost()
|
||||
const innerNode = new LGraphNode('Inner')
|
||||
host.subgraph.add(innerNode)
|
||||
|
||||
const result = repairValueWidget({
|
||||
hostNode: host,
|
||||
entry: buildEntry({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'nonexistent',
|
||||
plan: {
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: 'nonexistent'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'missingSourceWidget' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid plan kind', () => {
|
||||
it('throws on unsupported plan kinds', () => {
|
||||
const host = buildHost()
|
||||
const entry = buildEntry({
|
||||
sourceNodeId: '7',
|
||||
sourceWidgetName: 'seed',
|
||||
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
})
|
||||
|
||||
expect(() => repairValueWidget({ hostNode: host, entry })).toThrow(
|
||||
/invalid plan kind/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
173
src/core/graph/subgraph/migration/repairValueWidget.ts
Normal file
173
src/core/graph/subgraph/migration/repairValueWidget.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
type RepairValueWidgetResult =
|
||||
| { ok: true; subgraphInputName: string }
|
||||
| { ok: false; reason: ProxyWidgetQuarantineReason }
|
||||
|
||||
interface RepairValueWidgetArgs {
|
||||
hostNode: SubgraphNode
|
||||
entry: PendingMigrationEntry
|
||||
}
|
||||
|
||||
function findHostInputForLinkedSource(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
subgraphInputName: string | undefined
|
||||
):
|
||||
| { kind: 'none' }
|
||||
| { kind: 'one'; input: INodeInputSlot }
|
||||
| { kind: 'ambiguous' } {
|
||||
const candidates = subgraphInputName
|
||||
? hostNode.inputs.filter((input) => input.name === subgraphInputName)
|
||||
: hostNode.inputs
|
||||
|
||||
const matches = candidates.filter((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return false
|
||||
return (
|
||||
widget.sourceNodeId === sourceNodeId &&
|
||||
widget.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
})
|
||||
if (matches.length === 0) return { kind: 'none' }
|
||||
if (matches.length === 1) return { kind: 'one', input: matches[0] }
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
function applyHostValue(
|
||||
widget: IBaseWidget,
|
||||
hostValue: PendingMigrationEntry['hostValue']
|
||||
): void {
|
||||
if (hostValue === HOST_VALUE_HOLE) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(hostValue)
|
||||
return
|
||||
}
|
||||
widget.value = hostValue as TWidgetValue
|
||||
}
|
||||
|
||||
function repairAlreadyLinked(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingMigrationEntry
|
||||
): RepairValueWidgetResult {
|
||||
const hostInput = findHostInputForLinkedSource(
|
||||
hostNode,
|
||||
entry.normalized.sourceNodeId,
|
||||
entry.normalized.sourceWidgetName,
|
||||
entry.plan.kind === 'alreadyLinked'
|
||||
? entry.plan.subgraphInputName
|
||||
: undefined
|
||||
)
|
||||
if (hostInput.kind === 'ambiguous') {
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
if (hostInput.kind === 'none' || !hostInput.input._widget) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput.input._widget, entry.hostValue)
|
||||
return { ok: true, subgraphInputName: hostInput.input.name }
|
||||
}
|
||||
|
||||
function repairCreateSubgraphInput(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingMigrationEntry,
|
||||
sourceWidgetName: string
|
||||
): RepairValueWidgetResult {
|
||||
const subgraph = hostNode.subgraph
|
||||
const sourceNode: LGraphNode | null = subgraph.getNodeById(
|
||||
entry.normalized.sourceNodeId
|
||||
)
|
||||
if (!sourceNode) {
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(w) => w.name === sourceWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
|
||||
const slot: INodeInputSlot | undefined =
|
||||
sourceNode.getSlotFromWidget(sourceWidget)
|
||||
if (!slot) {
|
||||
// TODO(adr-0009): When the source widget has no backing input slot,
|
||||
// promotion currently has no canonical path to wire it through a
|
||||
// SubgraphInput without first synthesizing the slot. The wiring slice
|
||||
// (slice 5) will reconcile this — for now we surface a quarantine reason
|
||||
// so the entry is preserved and visible to the user.
|
||||
console.warn(
|
||||
'[repairValueWidget] source widget has no backing input slot; quarantining',
|
||||
{
|
||||
sourceNodeId: entry.normalized.sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
)
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
const existingNames = subgraph.inputs.map((input) => input.name)
|
||||
const desiredName = nextUniqueName(sourceWidgetName, existingNames)
|
||||
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
|
||||
|
||||
const newSubgraphInput = subgraph.addInput(desiredName, slotType)
|
||||
// Mirror LGraphNode.configure: input.label → widget.label propagation.
|
||||
if (slot.label !== undefined) newSubgraphInput.label = slot.label
|
||||
const link = newSubgraphInput.connect(slot, sourceNode)
|
||||
if (!link) {
|
||||
subgraph.removeInput(newSubgraphInput)
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
if (!hostInput?._widget) {
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
applyHostValue(hostInput._widget, entry.hostValue)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair a single legacy proxy entry into its canonical linked SubgraphInput.
|
||||
*
|
||||
* Two valid plan kinds: `'alreadyLinked'` and `'createSubgraphInput'`. Any
|
||||
* other plan kind is a programmer error (caller bug) and throws. Failures
|
||||
* during repair return a quarantine reason; the caller is expected to
|
||||
* append the entry to the host's quarantine via `appendHostQuarantine`.
|
||||
*/
|
||||
export function repairValueWidget(
|
||||
args: RepairValueWidgetArgs
|
||||
): RepairValueWidgetResult {
|
||||
const { hostNode, entry } = args
|
||||
const { plan } = entry
|
||||
|
||||
if (plan.kind === 'alreadyLinked') {
|
||||
return repairAlreadyLinked(hostNode, entry)
|
||||
}
|
||||
|
||||
if (plan.kind === 'createSubgraphInput') {
|
||||
return repairCreateSubgraphInput(hostNode, entry, plan.sourceWidgetName)
|
||||
}
|
||||
|
||||
throw new Error(`repairValueWidget: invalid plan kind ${plan.kind}`)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
|
||||
import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook'
|
||||
|
||||
/**
|
||||
* Register the proxyWidget migration flush as the late-bound hook that
|
||||
* `LGraph.configure()` calls for every host SubgraphNode it materializes.
|
||||
*
|
||||
* Called once during app initialization. Safe to call multiple times — the
|
||||
* registry holds a single function reference.
|
||||
*/
|
||||
export function wireProxyWidgetMigrationFlush(): void {
|
||||
setSubgraphMigrationFlushHook(({ hostNode, nodeData }) => {
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
hostWidgetValues: nodeData?.widgets_values
|
||||
})
|
||||
})
|
||||
}
|
||||
283
src/core/graph/subgraph/preview/previewExposureChain.test.ts
Normal file
283
src/core/graph/subgraph/preview/previewExposureChain.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type { PreviewExposureChainContext } from './previewExposureChain'
|
||||
import { resolvePreviewExposureChain } from './previewExposureChain'
|
||||
|
||||
const rootGraphA = 'root-a' as UUID
|
||||
const rootGraphB = 'root-b' as UUID
|
||||
|
||||
interface FixtureExposure extends PreviewExposure {}
|
||||
|
||||
interface NestedHostMapping {
|
||||
fromHostLocator: string
|
||||
fromSourceNodeId: string
|
||||
toRootGraphId: UUID
|
||||
toHostLocator: string
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
exposureMap: Map<string, FixtureExposure[]>,
|
||||
nested: NestedHostMapping[]
|
||||
): PreviewExposureChainContext {
|
||||
return {
|
||||
getExposures(rootGraphId, hostLocator) {
|
||||
return exposureMap.get(`${rootGraphId}|${hostLocator}`) ?? []
|
||||
},
|
||||
resolveNestedHost(_rootGraphId, hostLocator, sourceNodeId) {
|
||||
const match = nested.find(
|
||||
(n) =>
|
||||
n.fromHostLocator === hostLocator &&
|
||||
n.fromSourceNodeId === sourceNodeId
|
||||
)
|
||||
if (!match) return undefined
|
||||
return {
|
||||
rootGraphId: match.toRootGraphId,
|
||||
hostNodeLocator: match.toHostLocator
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe(resolvePreviewExposureChain, () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns undefined when the named exposure is not on the starting host', () => {
|
||||
const ctx = makeContext(new Map(), [])
|
||||
expect(
|
||||
resolvePreviewExposureChain(rootGraphA, 'host-a', 'absent', ctx)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a single-step chain when the source is a leaf (no nested host)', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-a`,
|
||||
[
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-a',
|
||||
'preview',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
steps: [
|
||||
{
|
||||
rootGraphId: rootGraphA,
|
||||
hostNodeLocator: 'host-a',
|
||||
exposure: {
|
||||
name: 'preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
}
|
||||
],
|
||||
leaf: {
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('walks one nested host and returns a two-step chain', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-outer`,
|
||||
[
|
||||
{
|
||||
name: 'outer-preview',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphA}|host-inner`,
|
||||
[
|
||||
{
|
||||
name: 'inner-preview',
|
||||
sourceNodeId: 'leaf-node',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-outer',
|
||||
fromSourceNodeId: '99',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-inner'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-outer',
|
||||
'outer-preview',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result?.steps).toHaveLength(2)
|
||||
expect(result?.steps[0].hostNodeLocator).toBe('host-outer')
|
||||
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: 'leaf-node',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('walks two nested hosts (three-step chain) crossing a root graph boundary', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-1`,
|
||||
[
|
||||
{
|
||||
name: 'p1',
|
||||
sourceNodeId: 'sub-a',
|
||||
sourcePreviewName: 'p2'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphA}|host-2`,
|
||||
[
|
||||
{
|
||||
name: 'p2',
|
||||
sourceNodeId: 'sub-b',
|
||||
sourcePreviewName: 'p3'
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
`${rootGraphB}|host-3`,
|
||||
[
|
||||
{
|
||||
name: 'p3',
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-1',
|
||||
fromSourceNodeId: 'sub-a',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-2'
|
||||
},
|
||||
{
|
||||
fromHostLocator: 'host-2',
|
||||
fromSourceNodeId: 'sub-b',
|
||||
toRootGraphId: rootGraphB,
|
||||
toHostLocator: 'host-3'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(rootGraphA, 'host-1', 'p1', ctx)
|
||||
|
||||
expect(result?.steps).toHaveLength(3)
|
||||
expect(result?.steps.map((s) => s.exposure.name)).toEqual([
|
||||
'p1',
|
||||
'p2',
|
||||
'p3'
|
||||
])
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphB,
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('terminates at outer step when nested host has no matching exposure', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-outer`,
|
||||
[
|
||||
{
|
||||
name: 'outer',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'missing-on-inner'
|
||||
}
|
||||
]
|
||||
],
|
||||
[`${rootGraphA}|host-inner`, []]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-outer',
|
||||
fromSourceNodeId: '99',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-inner'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-outer',
|
||||
'outer',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(result?.steps).toHaveLength(1)
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'missing-on-inner'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects cycles, warns, and stops walking', () => {
|
||||
const exposureMap = new Map<string, FixtureExposure[]>([
|
||||
[
|
||||
`${rootGraphA}|host-a`,
|
||||
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
|
||||
]
|
||||
])
|
||||
const ctx = makeContext(exposureMap, [
|
||||
{
|
||||
fromHostLocator: 'host-a',
|
||||
fromSourceNodeId: 'sub',
|
||||
toRootGraphId: rootGraphA,
|
||||
toHostLocator: 'host-a'
|
||||
}
|
||||
])
|
||||
|
||||
const result = resolvePreviewExposureChain(
|
||||
rootGraphA,
|
||||
'host-a',
|
||||
'cyclic',
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cycle detected')
|
||||
)
|
||||
expect(result?.steps).toHaveLength(1)
|
||||
expect(result?.leaf.sourceNodeId).toBe('sub')
|
||||
})
|
||||
})
|
||||
136
src/core/graph/subgraph/preview/previewExposureChain.ts
Normal file
136
src/core/graph/subgraph/preview/previewExposureChain.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import type {
|
||||
ResolvedPreviewChain,
|
||||
ResolvedPreviewChainStep
|
||||
} from './previewExposureTypes'
|
||||
|
||||
/**
|
||||
* Lookup callbacks the chain walker needs to follow nested-host boundaries.
|
||||
*
|
||||
* The walker is graph-agnostic: it does not import LGraph. The store layer or
|
||||
* test harness wires up these callbacks against a real graph or a fixture.
|
||||
*/
|
||||
export interface PreviewExposureChainContext {
|
||||
/**
|
||||
* Return preview exposures registered for a host execution path.
|
||||
*/
|
||||
getExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): readonly PreviewExposure[]
|
||||
/**
|
||||
* Resolve a source node to its nested host execution path when it is a
|
||||
* SubgraphNode.
|
||||
*/
|
||||
resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
sourceNodeId: string
|
||||
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
|
||||
}
|
||||
|
||||
const MAX_CHAIN_DEPTH = 32
|
||||
|
||||
function visitedKey(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string
|
||||
): string {
|
||||
return `${rootGraphId}|${hostNodeLocator}|${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a preview-exposure chain from an outer host through any nested-host
|
||||
* boundaries down to a leaf source.
|
||||
*
|
||||
* @returns The {@link ResolvedPreviewChain} or `undefined` when the named
|
||||
* exposure does not exist on the starting host.
|
||||
*
|
||||
* @remarks
|
||||
* Cycles are detected via a visited set; a cycle terminates the walk at the
|
||||
* cycle entry and emits a `console.warn`. The walk also terminates at a fixed
|
||||
* `MAX_CHAIN_DEPTH` to defend against pathological inputs.
|
||||
*/
|
||||
export function resolvePreviewExposureChain(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string,
|
||||
ctx: PreviewExposureChainContext
|
||||
): ResolvedPreviewChain | undefined {
|
||||
const steps: ResolvedPreviewChainStep[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
let currentRootGraphId: UUID = rootGraphId
|
||||
let currentHost = hostNodeLocator
|
||||
let currentName = name
|
||||
|
||||
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
||||
const key = visitedKey(currentRootGraphId, currentHost, currentName)
|
||||
if (visited.has(key)) {
|
||||
console.warn(
|
||||
`[previewExposureChain] cycle detected at ${key}; terminating walk`
|
||||
)
|
||||
break
|
||||
}
|
||||
visited.add(key)
|
||||
|
||||
const exposures = ctx.getExposures(currentRootGraphId, currentHost)
|
||||
const exposure = exposures.find((e) => e.name === currentName)
|
||||
if (!exposure) {
|
||||
if (steps.length === 0) return undefined
|
||||
// Source on outer host pointed at a non-existent inner exposure; treat
|
||||
// the outer step as the leaf and stop walking.
|
||||
const last = steps[steps.length - 1].exposure
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: last.sourceNodeId,
|
||||
sourcePreviewName: last.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
steps.push({
|
||||
rootGraphId: currentRootGraphId,
|
||||
hostNodeLocator: currentHost,
|
||||
exposure
|
||||
})
|
||||
|
||||
const nested = ctx.resolveNestedHost(
|
||||
currentRootGraphId,
|
||||
currentHost,
|
||||
exposure.sourceNodeId
|
||||
)
|
||||
if (!nested) {
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourcePreviewName: exposure.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentRootGraphId = nested.rootGraphId
|
||||
currentHost = nested.hostNodeLocator
|
||||
currentName = exposure.sourcePreviewName
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[previewExposureChain] max chain depth (${MAX_CHAIN_DEPTH}) reached; terminating walk`
|
||||
)
|
||||
if (steps.length === 0) return undefined
|
||||
const last = steps[steps.length - 1].exposure
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: currentRootGraphId,
|
||||
sourceNodeId: last.sourceNodeId,
|
||||
sourcePreviewName: last.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/core/graph/subgraph/preview/previewExposureTypes.ts
Normal file
31
src/core/graph/subgraph/preview/previewExposureTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
/**
|
||||
* One step along a chain of preview exposures rooted at an outer host.
|
||||
*/
|
||||
export interface ResolvedPreviewChainStep {
|
||||
rootGraphId: UUID
|
||||
hostNodeLocator: string
|
||||
exposure: PreviewExposure
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of walking a preview-exposure chain through zero or more nested
|
||||
* subgraph hosts.
|
||||
*
|
||||
* @remarks
|
||||
* `steps` is ordered outer-most first. A single-link chain has exactly one
|
||||
* step. `leaf` describes the final non-host source — the interior node id and
|
||||
* preview name reached at the bottom of the walk.
|
||||
*/
|
||||
export interface ResolvedPreviewChain {
|
||||
steps: readonly ResolvedPreviewChainStep[]
|
||||
leaf: {
|
||||
rootGraphId: UUID
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
}
|
||||
|
||||
export type { PreviewExposure }
|
||||
@@ -10,20 +10,33 @@ export interface ResolvedPromotedWidget {
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy proxyWidget tuple shape carried through migration. The optional
|
||||
* `disambiguatingSourceNodeId` is read from legacy `properties.proxyWidgets`
|
||||
* payloads only — canonical runtime state never sets it. See ADR 0009.
|
||||
*/
|
||||
export interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
/**
|
||||
* Identity of the immediate interior child whose widget (or input slot, for
|
||||
* nested SubgraphNode children) this view exposes. Per ADR 0009 each
|
||||
* SubgraphNode is opaque: the parent's promoted view references the
|
||||
* immediate child only and does not flatten to deeper origins.
|
||||
*/
|
||||
readonly sourceNodeId: string
|
||||
readonly sourceWidgetName: string
|
||||
|
||||
/**
|
||||
* The original leaf-level source node ID, used to distinguish promoted
|
||||
* widgets with the same name on the same intermediate node. Unlike
|
||||
* `sourceNodeId` (the direct interior node), this traces to the deepest
|
||||
* origin.
|
||||
* Per-instance value hydration that writes only to host widget state, never
|
||||
* cascading into the shared interior widget. Used during configure/clone.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
@@ -27,6 +28,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export function getPromotedWidgetHostStateName(
|
||||
widget: IPromotedWidgetView
|
||||
): string {
|
||||
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
|
||||
}
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
@@ -41,6 +48,14 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
function isValueControlWidget(widget: IBaseWidget): boolean {
|
||||
return (
|
||||
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
|
||||
typeof widget.beforeQueued === 'function' &&
|
||||
typeof widget.afterQueued === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
@@ -56,7 +71,6 @@ export function createPromotedWidgetView(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
@@ -64,7 +78,6 @@ export function createPromotedWidgetView(
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
@@ -100,7 +113,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
@@ -150,12 +162,17 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
const hostState = this.getHostWidgetState()
|
||||
if (hostState && isWidgetValue(hostState.value)) return hostState.value
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.setHostWidgetState(value)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -200,6 +217,43 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
this.subgraphNode.id,
|
||||
this.hostWidgetStateName
|
||||
)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
useWidgetValueStore().registerWidget(this.graphId, {
|
||||
nodeId: this.subgraphNode.id,
|
||||
name: this.hostWidgetStateName,
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
// Clone — never share the interior widget's options reference, or
|
||||
// host-state mutations (e.g. disabled toggle) leak into the shared
|
||||
// interior across every SubgraphNode instance.
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
private get hostWidgetStateName(): string {
|
||||
return getPromotedWidgetHostStateName(this)
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
@@ -217,6 +271,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a value into this host's widget store entry without cascading into
|
||||
* the shared interior widget — the only safe path for per-instance hydration
|
||||
* during `configure()` and clone, where multiple SubgraphNode instances
|
||||
* reference the same shared interior nodes.
|
||||
*/
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached bound subgraph slot reference, refreshing only when
|
||||
* the subgraph node's input list has changed (length mismatch).
|
||||
@@ -351,14 +415,50 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
beforeQueued(): void {
|
||||
// Source widgets linked through subgraph inputs are inert for prompt
|
||||
// serialization. Control-after-generate is applied to the promoted host
|
||||
// value in afterQueued so the next prompt uses the updated SubgraphNode
|
||||
// value, not the linked source value.
|
||||
}
|
||||
|
||||
afterQueued(): void {
|
||||
this.applyValueControlToHost()
|
||||
}
|
||||
|
||||
private applyValueControlToHost(): void {
|
||||
const resolved = this.resolveAtHost()
|
||||
const controlWidget =
|
||||
resolved?.widget.linkedWidgets?.find(isValueControlWidget)
|
||||
if (!controlWidget) return
|
||||
|
||||
const mode = controlWidget.value
|
||||
if (mode === 'fixed') return
|
||||
|
||||
const current = this.value
|
||||
if (typeof current !== 'number') return
|
||||
|
||||
const { min = 0, max = 1, step2 = 1 } = this.options
|
||||
let next = current
|
||||
if (mode === 'increment') next += step2
|
||||
else if (mode === 'decrement') next -= step2
|
||||
else if (mode === 'randomize') {
|
||||
const safeMax = Math.min(1125899906842624, max)
|
||||
const safeMin = Math.max(-1125899906842624, min)
|
||||
const range = (safeMax - safeMin) / step2
|
||||
next = Math.floor(Math.random() * range) * step2 + safeMin
|
||||
}
|
||||
next = Math.min(Math.max(next, min), max)
|
||||
this.value = next
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -372,8 +472,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
this.sourceWidgetName
|
||||
)
|
||||
const resolved = result.status === 'resolved' ? result.resolved : undefined
|
||||
|
||||
@@ -413,9 +512,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName &&
|
||||
boundWidget.disambiguatingSourceNodeId ===
|
||||
this.disambiguatingSourceNodeId
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ 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'
|
||||
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -19,11 +24,16 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputAtIndex,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -112,58 +122,64 @@ 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 = String(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,50 @@ 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 = String(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 +312,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 +321,21 @@ describe('promoteRecommendedWidgets', () => {
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
const hostLocator = String(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 +348,17 @@ describe('promoteRecommendedWidgets', () => {
|
||||
// which eagerly registers $$canvas-image-preview for supported node types
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const store = usePromotionStore()
|
||||
const hostLocator = String(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 +382,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)
|
||||
})
|
||||
@@ -416,3 +483,225 @@ describe('isLinkedPromotion', () => {
|
||||
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputsByName', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'third',
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
expect(host.inputs.map((input) => input.name)).toEqual([
|
||||
'third',
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders promoted widgets on the host node from subgraph input order', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
subgraph.addInput('plain', 'STRING')
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
const [secondSlot, firstSlot] = subgraph.inputs
|
||||
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
|
||||
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
|
||||
|
||||
expect(secondLink?.origin_slot).toBe(0)
|
||||
expect(firstLink?.origin_slot).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputAtIndex', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('moves host widget values with dragged input rows', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 0, 1)
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(host.widgets.map((widget) => widget.value)).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph link slot indices after moving a row', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 0, 1)
|
||||
|
||||
const [secondSlot, firstSlot] = subgraph.inputs
|
||||
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
|
||||
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
|
||||
|
||||
expect(secondLink?.origin_slot).toBe(0)
|
||||
expect(firstLink?.origin_slot).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reorders duplicate-named promoted inputs by widget identity', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('text', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('text', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
host.widgets[0].value = 'first value'
|
||||
host.widgets[1].value = 'second value'
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
|
||||
|
||||
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getPromotedWidgetHostStateName } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
@@ -8,6 +9,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,14 +17,23 @@ 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 { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
}
|
||||
@@ -37,7 +48,20 @@ export function isLinkedPromotion(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): boolean {
|
||||
return subgraphNode.inputs.some((input) => {
|
||||
return (
|
||||
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the host input on `subgraphNode` whose `_widget` is the
|
||||
* `PromotedWidgetView` for `(sourceNodeId, sourceWidgetName)`. */
|
||||
function findHostInputForPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
w &&
|
||||
@@ -48,9 +72,175 @@ export function isLinkedPromotion(
|
||||
})
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByName(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedInputNames: readonly string[]
|
||||
): void {
|
||||
const order = new Map(
|
||||
orderedInputNames.map((name, index) => [name, index] as const)
|
||||
)
|
||||
const byOrder = <T extends { name: string }>(left: T, right: T) => {
|
||||
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
|
||||
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
|
||||
return leftOrder - rightOrder
|
||||
}
|
||||
|
||||
const orderedIndices = subgraphNode.subgraph.inputs
|
||||
.map((input, index) => ({ input, index }))
|
||||
.sort((left, right) => byOrder(left.input, right.input))
|
||||
.map(({ index }) => index)
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputsByWidgetOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedWidgets: readonly IBaseWidget[]
|
||||
): void {
|
||||
const remainingIndices = new Set(subgraphNode.inputs.keys())
|
||||
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
|
||||
for (const index of remainingIndices) {
|
||||
const widget = subgraphNode.inputs[index]?._widget
|
||||
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
|
||||
remainingIndices.delete(index)
|
||||
return [index]
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
for (const index of remainingIndices) orderedIndices.push(index)
|
||||
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputAtIndex(
|
||||
subgraphNode: SubgraphNode,
|
||||
oldPosition: number,
|
||||
newPosition: number
|
||||
): void {
|
||||
if (
|
||||
oldPosition < 0 ||
|
||||
newPosition < 0 ||
|
||||
oldPosition >= subgraphNode.subgraph.inputs.length ||
|
||||
newPosition >= subgraphNode.subgraph.inputs.length
|
||||
)
|
||||
return
|
||||
|
||||
const orderedIndices = subgraphNode.subgraph.inputs.map((_, index) => index)
|
||||
const [movedIndex] = orderedIndices.splice(oldPosition, 1)
|
||||
if (movedIndex !== undefined)
|
||||
orderedIndices.splice(newPosition, 0, movedIndex)
|
||||
|
||||
applySubgraphInputOrder(subgraphNode, orderedIndices)
|
||||
}
|
||||
|
||||
function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const rows = subgraphNode.subgraph.inputs.map((input, index) => ({
|
||||
subgraphInput: input,
|
||||
hostInput: subgraphNode.inputs[index],
|
||||
value: getExplicitHostWidgetValue(
|
||||
subgraphNode,
|
||||
subgraphNode.inputs[index]?._widget
|
||||
)
|
||||
}))
|
||||
|
||||
const orderedRows = orderedIndices.flatMap((index) => rows[index] ?? [])
|
||||
|
||||
subgraphNode.subgraph.inputs.splice(
|
||||
0,
|
||||
subgraphNode.subgraph.inputs.length,
|
||||
...orderedRows.map((row) => row.subgraphInput)
|
||||
)
|
||||
subgraphNode.inputs.splice(
|
||||
0,
|
||||
subgraphNode.inputs.length,
|
||||
...orderedRows.flatMap((row) => row.hostInput ?? [])
|
||||
)
|
||||
|
||||
for (const [index, input] of subgraphNode.subgraph.inputs.entries()) {
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (link) link.origin_slot = index
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of orderedRows) {
|
||||
const widget = row.hostInput?._widget
|
||||
if (widget && row.value !== undefined) widget.value = row.value
|
||||
}
|
||||
}
|
||||
|
||||
function getExplicitHostWidgetValue(
|
||||
subgraphNode: SubgraphNode,
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
getPromotedWidgetHostStateName(widget)
|
||||
)
|
||||
return state && isWidgetValue(state.value) ? state.value : undefined
|
||||
}
|
||||
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
return w.sourceNodeId
|
||||
}
|
||||
|
||||
function isPreviewExposed(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
const hostLocator = String(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
|
||||
) || isPreviewExposed(subgraphNode, source)
|
||||
)
|
||||
}
|
||||
|
||||
function toPromotionSource(
|
||||
@@ -59,21 +249,75 @@ function toPromotionSource(
|
||||
): PromotedWidgetSource {
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
function promotePreviewViaExposure(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNode: LGraphNode,
|
||||
sourcePreviewName: string
|
||||
): void {
|
||||
const store = usePreviewExposureStore()
|
||||
const rootGraphId = subgraphNode.rootGraph.id
|
||||
const hostLocator = String(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 +342,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 +369,40 @@ 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 hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
parent.subgraph.removeInput(linkedInput)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
const previewStore = usePreviewExposureStore()
|
||||
const hostLocator = String(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 +435,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 +471,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 +529,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 +540,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 +555,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,43 +566,42 @@ 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',
|
||||
'[subgraphInputs] Pruned disconnected promoted widget inputs',
|
||||
removedEntries,
|
||||
{
|
||||
graphId: subgraphNode.rootGraph.id,
|
||||
@@ -350,24 +610,22 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
)
|
||||
}
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
|
||||
refreshPromotedWidgetRendering([subgraphNode])
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
|
||||
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
|
||||
level: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -30,7 +30,6 @@ type PromotedWidgetStub = Pick<
|
||||
> & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
}
|
||||
|
||||
@@ -52,8 +51,7 @@ function createPromotedWidget(
|
||||
name: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
node?: SubgraphNode,
|
||||
disambiguatingSourceNodeId?: string
|
||||
node?: SubgraphNode
|
||||
): IBaseWidget {
|
||||
const promotedWidget: PromotedWidgetStub = {
|
||||
name,
|
||||
@@ -63,7 +61,6 @@ function createPromotedWidget(
|
||||
value: undefined,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId,
|
||||
node
|
||||
}
|
||||
return promotedWidget as IBaseWidget
|
||||
@@ -97,27 +94,6 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
|
||||
const host = createHostNode(100)
|
||||
const sourceNode = addNodeToHost(host, 'source')
|
||||
sourceNode.widgets = [
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
|
||||
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
|
||||
]
|
||||
|
||||
const resolved = resolvePromotedWidgetAtHost(
|
||||
host,
|
||||
String(sourceNode.id),
|
||||
'text',
|
||||
'2'
|
||||
)
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveConcretePromotedWidget', () => {
|
||||
|
||||
@@ -20,30 +20,21 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
|
||||
function traversePromotedWidgetChain(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
const visited = new Set<string>()
|
||||
const hostUidByObject = new WeakMap<SubgraphNode, number>()
|
||||
let nextHostUid = 0
|
||||
const visitedByHost = new WeakMap<SubgraphNode, Set<string>>()
|
||||
let currentHost = hostNode
|
||||
let currentNodeId = nodeId
|
||||
let currentWidgetName = widgetName
|
||||
let currentSourceNodeId = sourceNodeId
|
||||
|
||||
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
|
||||
let hostUid = hostUidByObject.get(currentHost)
|
||||
if (hostUid === undefined) {
|
||||
hostUid = nextHostUid
|
||||
nextHostUid += 1
|
||||
hostUidByObject.set(currentHost, hostUid)
|
||||
}
|
||||
|
||||
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
|
||||
const key = `${currentNodeId}:${currentWidgetName}`
|
||||
const visited = visitedByHost.get(currentHost) ?? new Set<string>()
|
||||
if (visited.has(key)) {
|
||||
return { status: 'failure', failure: 'cycle' }
|
||||
}
|
||||
visited.add(key)
|
||||
visitedByHost.set(currentHost, visited)
|
||||
|
||||
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
|
||||
if (!sourceNode) {
|
||||
@@ -52,8 +43,7 @@ function traversePromotedWidgetChain(
|
||||
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
sourceNode.widgets,
|
||||
currentWidgetName,
|
||||
currentSourceNodeId
|
||||
currentWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
@@ -73,7 +63,6 @@ function traversePromotedWidgetChain(
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
currentSourceNodeId = undefined
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
@@ -81,34 +70,20 @@ function traversePromotedWidgetChain(
|
||||
|
||||
function findWidgetByIdentity(
|
||||
widgets: IBaseWidget[] | undefined,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): IBaseWidget | undefined {
|
||||
if (!widgets) return undefined
|
||||
|
||||
if (sourceNodeId) {
|
||||
return widgets.find(
|
||||
(entry) =>
|
||||
isPromotedWidgetView(entry) &&
|
||||
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
|
||||
sourceNodeId &&
|
||||
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
|
||||
)
|
||||
}
|
||||
|
||||
return widgets.find((entry) => entry.name === widgetName)
|
||||
return widgets?.find((entry) => entry.name === widgetName)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetAtHost(
|
||||
hostNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
const node = hostNode.subgraph.getNodeById(nodeId)
|
||||
if (!node) return undefined
|
||||
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
|
||||
const widget = findWidgetByIdentity(node.widgets, widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
@@ -117,11 +92,10 @@ export function resolvePromotedWidgetAtHost(
|
||||
export function resolveConcretePromotedWidget(
|
||||
hostNode: LGraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
sourceNodeId?: string
|
||||
widgetName: string
|
||||
): PromotedWidgetResolutionResult {
|
||||
if (!hostNode.isSubgraphNode()) {
|
||||
return { status: 'failure', failure: 'invalid-host' }
|
||||
}
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
|
||||
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ export function resolvePromotedWidgetSource(
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName,
|
||||
widget.disambiguatingSourceNodeId
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
|
||||
|
||||
type ResolvedSubgraphInputTarget = {
|
||||
nodeId: string
|
||||
widgetName: string
|
||||
sourceNodeId?: string
|
||||
}
|
||||
|
||||
export function resolveSubgraphInputTarget(
|
||||
@@ -17,29 +15,18 @@ export function resolveSubgraphInputTarget(
|
||||
node,
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.sourceWidgetName,
|
||||
sourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0009: each SubgraphNode is opaque. The promoted target is the
|
||||
// child SubgraphNode's input slot, not a deeper leaf widget.
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
}
|
||||
}
|
||||
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function setupSubgraph(
|
||||
innerNodeCount: number = 0
|
||||
): [SubgraphNode, LGraphNode[], string[]] {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
const innerNodes: LGraphNode[] = []
|
||||
for (let i = 0; i < innerNodeCount; i++) {
|
||||
const innerNode = new LGraphNode(`InnerNode${i}`)
|
||||
subgraph.add(innerNode)
|
||||
innerNodes.push(innerNode)
|
||||
}
|
||||
const innerIds = innerNodes.map((n) => String(n.id))
|
||||
return [subgraphNode, innerNodes, innerIds]
|
||||
}
|
||||
|
||||
describe('Subgraph proxyWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
test('Can add simple widget', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
test('Can add multiple widgets with same name', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
|
||||
for (const innerNode of innerNodes)
|
||||
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
|
||||
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
|
||||
]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
// Both views share the widget name; they're distinguished by sourceNodeId
|
||||
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
|
||||
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
|
||||
})
|
||||
test('Will reflect proxyWidgets order changes', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
|
||||
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets.length).toBe(2)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetA')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetB')
|
||||
|
||||
// Reorder
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
|
||||
])
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(subgraphNode.widgets[1].name).toBe('widgetA')
|
||||
})
|
||||
test('Will mirror changes to value', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
innerNodes[0].widgets![0].value = 'test'
|
||||
expect(subgraphNode.widgets[0].value).toBe('test')
|
||||
subgraphNode.widgets[0].value = 'test2'
|
||||
expect(innerNodes[0].widgets![0].value).toBe('test2')
|
||||
})
|
||||
test('Will not modify position or sizing of existing widgets', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
innerNodes[0].widgets[0].y = 10
|
||||
innerNodes[0].widgets[0].last_y = 11
|
||||
innerNodes[0].widgets[0].computedHeight = 12
|
||||
subgraphNode.widgets[0].y = 20
|
||||
subgraphNode.widgets[0].last_y = 21
|
||||
subgraphNode.widgets[0].computedHeight = 22
|
||||
expect(innerNodes[0].widgets[0].y).toBe(10)
|
||||
expect(innerNodes[0].widgets[0].last_y).toBe(11)
|
||||
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
|
||||
})
|
||||
test('Renders placeholder when interior widget is detached', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
|
||||
|
||||
// View resolves the interior widget's type
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
|
||||
// Remove interior widget — view falls back to disconnected state
|
||||
innerNodes[0].widgets.pop()
|
||||
expect(subgraphNode.widgets[0].type).toBe('button')
|
||||
|
||||
// Re-add — view resolves again
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
})
|
||||
test('Prevents duplicate promotion', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
|
||||
// Promote once
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
|
||||
// Try to promote again - should not create duplicate
|
||||
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: innerIds[0],
|
||||
sourceWidgetName: 'stringWidget'
|
||||
})
|
||||
expect(subgraphNode.widgets.length).toBe(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list and view cache', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(2)
|
||||
|
||||
const widgetToRemove = subgraphNode.widgets[0]
|
||||
subgraphNode.removeWidget(widgetToRemove)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toStrictEqual([
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
])
|
||||
})
|
||||
|
||||
test('removeWidget removes from promotion list', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
|
||||
subgraphNode.removeWidget(widgetA)
|
||||
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].name).toBe('widgetB')
|
||||
})
|
||||
|
||||
test('removeWidget cleans up input references', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
// Simulate an input referencing the widget
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
subgraphNode.removeWidget(view)
|
||||
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
|
||||
)
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
[
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
|
||||
]
|
||||
)
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual([
|
||||
[innerIds[0], 'widgetA'],
|
||||
[innerIds[0], 'widgetB']
|
||||
])
|
||||
})
|
||||
|
||||
test('multi-link representative is deterministic across repeated reads', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'shared_input', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
subgraphNode.graph!.add(subgraphNode)
|
||||
|
||||
const nodeA = new LGraphNode('NodeA')
|
||||
const inputA = nodeA.addInput('shared_input', '*')
|
||||
nodeA.addWidget('text', 'shared_input', 'first', () => {})
|
||||
inputA.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeA)
|
||||
|
||||
const nodeB = new LGraphNode('NodeB')
|
||||
const inputB = nodeB.addInput('shared_input', '*')
|
||||
nodeB.addWidget('text', 'shared_input', 'second', () => {})
|
||||
inputB.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeB)
|
||||
|
||||
const nodeC = new LGraphNode('NodeC')
|
||||
const inputC = nodeC.addInput('shared_input', '*')
|
||||
nodeC.addWidget('text', 'shared_input', 'third', () => {})
|
||||
inputC.widget = { name: 'shared_input' }
|
||||
subgraph.add(nodeC)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(inputA, nodeA)
|
||||
subgraph.inputNode.slots[0].connect(inputB, nodeB)
|
||||
subgraph.inputNode.slots[0].connect(inputC, nodeC)
|
||||
|
||||
const firstRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const secondRead = subgraphNode.widgets.map((w) => w.value)
|
||||
const thirdRead = subgraphNode.widgets.map((w) => w.value)
|
||||
|
||||
expect(firstRead).toStrictEqual(secondRead)
|
||||
expect(secondRead).toStrictEqual(thirdRead)
|
||||
expect(subgraphNode.widgets[0].value).toBe('first')
|
||||
})
|
||||
|
||||
test('3-level nested promotion resolves concrete widget type and value', () => {
|
||||
usePromotionStore()
|
||||
|
||||
// Level C: innermost subgraph with a concrete widget
|
||||
const subgraphC = createTestSubgraph({
|
||||
inputs: [{ name: 'deep_input', type: '*' }]
|
||||
})
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('deep_input', '*')
|
||||
concreteNode.addWidget('number', 'deep_input', 42, () => {})
|
||||
concreteInput.widget = { name: 'deep_input' }
|
||||
subgraphC.add(concreteNode)
|
||||
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
|
||||
|
||||
// Level B: middle subgraph containing C
|
||||
const subgraphB = createTestSubgraph({
|
||||
inputs: [{ name: 'mid_input', type: '*' }]
|
||||
})
|
||||
subgraphB.add(subgraphNodeC)
|
||||
subgraphNodeC._internalConfigureAfterSlots()
|
||||
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
|
||||
|
||||
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
|
||||
|
||||
// Level A: outermost subgraph containing B
|
||||
const subgraphA = createTestSubgraph({
|
||||
inputs: [{ name: 'outer_input', type: '*' }]
|
||||
})
|
||||
subgraphA.add(subgraphNodeB)
|
||||
subgraphNodeB._internalConfigureAfterSlots()
|
||||
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
|
||||
|
||||
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
|
||||
|
||||
// Outermost promoted widget should resolve through all 3 levels
|
||||
expect(subgraphNodeA.widgets).toHaveLength(1)
|
||||
expect(subgraphNodeA.widgets[0].type).toBe('number')
|
||||
expect(subgraphNodeA.widgets[0].value).toBe(42)
|
||||
|
||||
// Setting value at outermost level propagates to concrete widget
|
||||
subgraphNodeA.widgets[0].value = 99
|
||||
expect(concreteNode.widgets![0].value).toBe(99)
|
||||
})
|
||||
|
||||
test('removeWidget cleans up promotion and input, then re-promote works', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
const store = usePromotionStore()
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
|
||||
const view = subgraphNode.widgets[0]
|
||||
subgraphNode.addInput('stringWidget', '*')
|
||||
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
|
||||
input._widget = view
|
||||
|
||||
// Remove: should clean up store AND input reference
|
||||
subgraphNode.removeWidget(view)
|
||||
expect(
|
||||
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
|
||||
).toHaveLength(0)
|
||||
expect(input._widget).toBeUndefined()
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
|
||||
// Re-promote: should work correctly after cleanup
|
||||
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
|
||||
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
|
||||
])
|
||||
expect(subgraphNode.widgets).toHaveLength(1)
|
||||
expect(subgraphNode.widgets[0].type).toBe('text')
|
||||
expect(subgraphNode.widgets[0].value).toBe('value')
|
||||
})
|
||||
})
|
||||
88
src/core/schemas/previewExposureSchema.test.ts
Normal file
88
src/core/schemas/previewExposureSchema.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parsePreviewExposures } from './previewExposureSchema'
|
||||
|
||||
describe(parsePreviewExposures, () => {
|
||||
it('parses a valid array of preview exposure objects', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
},
|
||||
{
|
||||
name: 'preview2',
|
||||
sourceNodeId: '7',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parsePreviewExposures(undefined)).toEqual([])
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parsePreviewExposures('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(
|
||||
parsePreviewExposures({
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
).toEqual([])
|
||||
expect(parsePreviewExposures(42)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries are missing required fields', () => {
|
||||
expect(
|
||||
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries have wrong types', () => {
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 123,
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: 5,
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
35
src/core/schemas/previewExposureSchema.ts
Normal file
35
src/core/schemas/previewExposureSchema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export const previewExposureSchema = z.object({
|
||||
name: z.string(),
|
||||
sourceNodeId: z.string(),
|
||||
sourcePreviewName: z.string()
|
||||
})
|
||||
export type PreviewExposure = z.infer<typeof previewExposureSchema>
|
||||
|
||||
const previewExposuresPropertySchema = z.array(previewExposureSchema)
|
||||
|
||||
export function parsePreviewExposures(
|
||||
property: NodeProperty | undefined
|
||||
): PreviewExposure[] {
|
||||
if (property === undefined) return []
|
||||
|
||||
try {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = previewExposuresPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.previewExposures:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.previewExposures:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -3,11 +3,14 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
export const serializedProxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
])
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
export type SerializedProxyWidgetTuple = z.infer<
|
||||
typeof serializedProxyWidgetTupleSchema
|
||||
>
|
||||
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
|
||||
80
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
80
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
|
||||
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
|
||||
|
||||
const baseEntry = {
|
||||
originalEntry: ['10', 'seed'] as [string, string],
|
||||
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
|
||||
attemptedAtVersion: 1 as const
|
||||
}
|
||||
|
||||
describe(parseProxyWidgetErrorQuarantine, () => {
|
||||
it('parses a valid entry without hostValue', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine([baseEntry])).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('parses a valid entry with hostValue', () => {
|
||||
const entry = { ...baseEntry, hostValue: 42 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 2-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['10', 'seed'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 3-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['3', 'text', '1'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
] as const)('parses reason %s', (reason) => {
|
||||
const entry = { ...baseEntry, reason }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = JSON.stringify([baseEntry])
|
||||
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine(baseEntry)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when attemptedAtVersion is not 1', () => {
|
||||
const entry = { ...baseEntry, attemptedAtVersion: 2 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when reason is not in the enum', () => {
|
||||
const entry = { ...baseEntry, reason: 'somethingElse' }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when originalEntry is malformed', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['only-one'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
})
|
||||
56
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
56
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
|
||||
|
||||
export const proxyWidgetQuarantineReasonSchema = z.enum([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
])
|
||||
export type ProxyWidgetQuarantineReason = z.infer<
|
||||
typeof proxyWidgetQuarantineReasonSchema
|
||||
>
|
||||
|
||||
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
|
||||
originalEntry: serializedProxyWidgetTupleSchema,
|
||||
reason: proxyWidgetQuarantineReasonSchema,
|
||||
hostValue: z.unknown().optional(),
|
||||
attemptedAtVersion: z.literal(1)
|
||||
})
|
||||
|
||||
const proxyWidgetErrorQuarantinePropertySchema = z.array(
|
||||
proxyWidgetErrorQuarantineEntrySchema
|
||||
)
|
||||
|
||||
export type ProxyWidgetErrorQuarantineEntry = Omit<
|
||||
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
|
||||
'hostValue'
|
||||
> & { hostValue?: TWidgetValue }
|
||||
|
||||
export function parseProxyWidgetErrorQuarantine(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
if (property === undefined) return []
|
||||
|
||||
try {
|
||||
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ExportedSubgraphInstance,
|
||||
Positionable,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
/**
|
||||
* Registers a minimal SubgraphNode class for a subgraph definition
|
||||
* so that `LiteGraph.createNode(subgraphId)` works in tests.
|
||||
*/
|
||||
function registerSubgraphNodeType(subgraph: Subgraph): void {
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
}
|
||||
|
||||
const node = class extends SubgraphNode {
|
||||
constructor() {
|
||||
super(subgraph.rootGraph, subgraph, instanceData)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(node, 'title', { value: subgraph.name })
|
||||
LiteGraph.registerNodeType(subgraph.id, node)
|
||||
}
|
||||
|
||||
const registeredTypes: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const type of registeredTypes) {
|
||||
LiteGraph.unregisterNodeType(type)
|
||||
}
|
||||
registeredTypes.length = 0
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('_repointAncestorPromotions', () => {
|
||||
function setupParentSubgraphWithWidgets() {
|
||||
const parentSubgraph = createTestSubgraph({
|
||||
name: 'Parent Subgraph',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
const rootGraph = parentSubgraph.rootGraph
|
||||
|
||||
// We need to listen for new subgraph registrations so
|
||||
// LiteGraph.createNode works during convertToSubgraph
|
||||
rootGraph.events.addEventListener('subgraph-created', (e) => {
|
||||
const { subgraph } = e.detail
|
||||
registerSubgraphNodeType(subgraph)
|
||||
registeredTypes.push(subgraph.id)
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior Node')
|
||||
interiorNode.addInput('in', '*')
|
||||
interiorNode.addOutput('out', '*')
|
||||
interiorNode.addWidget('text', 'prompt', 'hello world', () => {})
|
||||
parentSubgraph.add(interiorNode)
|
||||
|
||||
// Create host SubgraphNode in root graph
|
||||
registerSubgraphNodeType(parentSubgraph)
|
||||
registeredTypes.push(parentSubgraph.id)
|
||||
const hostNode = createTestSubgraphNode(parentSubgraph)
|
||||
rootGraph.add(hostNode)
|
||||
|
||||
return { rootGraph, parentSubgraph, interiorNode, hostNode }
|
||||
}
|
||||
|
||||
it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => {
|
||||
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
|
||||
setupParentSubgraphWithWidgets()
|
||||
|
||||
// Promote the interior node's widget on the host
|
||||
const store = usePromotionStore()
|
||||
store.promote(rootGraph.id, hostNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'prompt'
|
||||
})
|
||||
|
||||
const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id)
|
||||
expect(beforeEntries).toHaveLength(1)
|
||||
expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id))
|
||||
|
||||
// Pack the interior node into a nested subgraph
|
||||
const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph(
|
||||
new Set<Positionable>([interiorNode])
|
||||
)
|
||||
|
||||
// After conversion, the host's promotion should be repointed
|
||||
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
|
||||
expect(afterEntries).toHaveLength(1)
|
||||
expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id))
|
||||
expect(afterEntries[0].sourceWidgetName).toBe('prompt')
|
||||
expect(afterEntries[0].disambiguatingSourceNodeId).toBe(
|
||||
String(interiorNode.id)
|
||||
)
|
||||
|
||||
// The nested subgraph node should also have the promotion
|
||||
const nestedEntries = store.getPromotions(
|
||||
rootGraph.id,
|
||||
nestedSubgraphNode.id
|
||||
)
|
||||
expect(nestedEntries).toHaveLength(1)
|
||||
expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id))
|
||||
expect(nestedEntries[0].sourceWidgetName).toBe('prompt')
|
||||
})
|
||||
|
||||
it('preserves promotions that reference non-moved nodes', () => {
|
||||
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
|
||||
setupParentSubgraphWithWidgets()
|
||||
|
||||
const remainingNode = new LGraphNode('Remaining Node')
|
||||
remainingNode.addWidget('text', 'widget_b', 'b', () => {})
|
||||
parentSubgraph.add(remainingNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.promote(rootGraph.id, hostNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'prompt'
|
||||
})
|
||||
store.promote(rootGraph.id, hostNode.id, {
|
||||
sourceNodeId: String(remainingNode.id),
|
||||
sourceWidgetName: 'widget_b'
|
||||
})
|
||||
|
||||
// Pack only the interiorNode
|
||||
parentSubgraph.convertToSubgraph(new Set<Positionable>([interiorNode]))
|
||||
|
||||
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
|
||||
expect(afterEntries).toHaveLength(2)
|
||||
|
||||
// The remaining node's promotion should be unchanged
|
||||
const remainingEntry = afterEntries.find(
|
||||
(e) => e.sourceWidgetName === 'widget_b'
|
||||
)
|
||||
expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id))
|
||||
expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined()
|
||||
|
||||
// The moved node's promotion should be repointed
|
||||
const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt')
|
||||
expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id))
|
||||
expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id))
|
||||
})
|
||||
|
||||
it('does not modify promotions when converting in root graph', () => {
|
||||
const parentSubgraph = createTestSubgraph({ name: 'Dummy' })
|
||||
const rootGraph = parentSubgraph.rootGraph
|
||||
|
||||
rootGraph.events.addEventListener('subgraph-created', (e) => {
|
||||
const { subgraph } = e.detail
|
||||
registerSubgraphNodeType(subgraph)
|
||||
registeredTypes.push(subgraph.id)
|
||||
})
|
||||
|
||||
const node = new LGraphNode('Root Node')
|
||||
node.addInput('in', '*')
|
||||
node.addOutput('out', '*')
|
||||
node.addWidget('text', 'value', 'test', () => {})
|
||||
rootGraph.add(node)
|
||||
|
||||
// Converting in root graph should not throw
|
||||
rootGraph.convertToSubgraph(new Set<Positionable>([node]))
|
||||
})
|
||||
|
||||
it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => {
|
||||
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
|
||||
setupParentSubgraphWithWidgets()
|
||||
|
||||
const store = usePromotionStore()
|
||||
store.promote(rootGraph.id, hostNode.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'prompt'
|
||||
})
|
||||
|
||||
// First pack: interior node → nested subgraph
|
||||
const { node: firstNestedNode } = parentSubgraph.convertToSubgraph(
|
||||
new Set<Positionable>([interiorNode])
|
||||
)
|
||||
|
||||
const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id)
|
||||
expect(afterFirstPack).toHaveLength(1)
|
||||
expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id))
|
||||
expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe(
|
||||
String(interiorNode.id)
|
||||
)
|
||||
|
||||
// Second pack: nested subgraph → another level of nesting
|
||||
const { node: secondNestedNode } = parentSubgraph.convertToSubgraph(
|
||||
new Set<Positionable>([firstNestedNode])
|
||||
)
|
||||
|
||||
// After second pack, promotion should use the disambiguatingSourceNodeId
|
||||
// as fallback and point to the new nested node
|
||||
const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id)
|
||||
expect(afterSecondPack).toHaveLength(1)
|
||||
expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id))
|
||||
expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe(
|
||||
String(interiorNode.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('repoints promotions for multiple host instances of the same subgraph', () => {
|
||||
const parentSubgraph = createTestSubgraph({
|
||||
name: 'Shared Parent Subgraph',
|
||||
inputs: [{ name: 'input', type: '*' }],
|
||||
outputs: [{ name: 'output', type: '*' }]
|
||||
})
|
||||
const rootGraph = parentSubgraph.rootGraph
|
||||
|
||||
rootGraph.events.addEventListener('subgraph-created', (e) => {
|
||||
const { subgraph } = e.detail
|
||||
registerSubgraphNodeType(subgraph)
|
||||
registeredTypes.push(subgraph.id)
|
||||
})
|
||||
|
||||
const interiorNode = new LGraphNode('Interior Node')
|
||||
interiorNode.addInput('in', '*')
|
||||
interiorNode.addOutput('out', '*')
|
||||
interiorNode.addWidget('text', 'prompt', 'shared', () => {})
|
||||
parentSubgraph.add(interiorNode)
|
||||
|
||||
// Create TWO host SubgraphNodes pointing to the same subgraph
|
||||
registerSubgraphNodeType(parentSubgraph)
|
||||
registeredTypes.push(parentSubgraph.id)
|
||||
|
||||
const hostNode1 = createTestSubgraphNode(parentSubgraph)
|
||||
const hostNode2 = createTestSubgraphNode(parentSubgraph)
|
||||
rootGraph.add(hostNode1)
|
||||
rootGraph.add(hostNode2)
|
||||
|
||||
// Promote on both hosts
|
||||
const store = usePromotionStore()
|
||||
store.promote(rootGraph.id, hostNode1.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'prompt'
|
||||
})
|
||||
store.promote(rootGraph.id, hostNode2.id, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: 'prompt'
|
||||
})
|
||||
|
||||
// Pack the interior node
|
||||
const { node: nestedNode } = parentSubgraph.convertToSubgraph(
|
||||
new Set<Positionable>([interiorNode])
|
||||
)
|
||||
|
||||
// Both hosts' promotions should be repointed to the nested node
|
||||
const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id)
|
||||
expect(host1Promotions).toHaveLength(1)
|
||||
expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
|
||||
expect(host1Promotions[0].disambiguatingSourceNodeId).toBe(
|
||||
String(interiorNode.id)
|
||||
)
|
||||
|
||||
const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id)
|
||||
expect(host2Promotions).toHaveLength(1)
|
||||
expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
|
||||
expect(host2Promotions[0].disambiguatingSourceNodeId).toBe(
|
||||
String(interiorNode.id)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +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 { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
createTestSubgraphData,
|
||||
@@ -280,17 +280,17 @@ 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 preview and widget-value state', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const graph = new LGraph()
|
||||
const graphId = 'graph-clear-cleanup' as UUID
|
||||
graph.id = graphId
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
promotionStore.promote(graphId, 1 as NodeId, {
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
previewExposureStore.addExposure(graphId, `${graphId}:1`, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
@@ -305,27 +305,21 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
expect(
|
||||
previewExposureStore.getExposures(graphId, `${graphId}:1`)
|
||||
).toHaveLength(1)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
promotionStore.isPromotedByAny(graphId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -54,6 +50,7 @@ import type {
|
||||
Size
|
||||
} from './interfaces'
|
||||
import { LiteGraph, SubgraphNode } from './litegraph'
|
||||
import { runSubgraphMigrationFlushHook } from './subgraph/subgraphMigrationHook'
|
||||
import {
|
||||
alignOutsideContainer,
|
||||
alignToContainer,
|
||||
@@ -379,7 +376,7 @@ export class LGraph
|
||||
|
||||
const graphId = this.id
|
||||
if (this.isRootGraph && graphId !== zeroUuid) {
|
||||
usePromotionStore().clearGraph(graphId)
|
||||
usePreviewExposureStore().clearGraph(graphId)
|
||||
useWidgetValueStore().clearGraph(graphId)
|
||||
}
|
||||
|
||||
@@ -1927,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', {
|
||||
@@ -1946,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 }
|
||||
@@ -2735,6 +2656,16 @@ export class LGraph
|
||||
|
||||
this.updateExecutionOrder()
|
||||
|
||||
// ADR 0009: forward-ratchet legacy properties.proxyWidgets on each
|
||||
// host SubgraphNode. Late-bound hook (registered in app init) so the
|
||||
// LGraph layer doesn't pull in the PreviewExposureStore at module
|
||||
// load — that would create a circular dependency.
|
||||
for (const node of this._nodes) {
|
||||
if (!(node instanceof SubgraphNode)) continue
|
||||
if (node.properties?.proxyWidgets === undefined) continue
|
||||
runSubgraphMigrationFlushHook(node, nodeDataMap.get(node.id))
|
||||
}
|
||||
|
||||
this.onConfigure?.(data)
|
||||
this.incrementVersion()
|
||||
|
||||
|
||||
@@ -9065,9 +9065,9 @@ function remapProxyWidgets(
|
||||
|
||||
/**
|
||||
* Remaps pasted subgraph interior node IDs that would collide with existing
|
||||
* node IDs in the root graph. Also patches subgraph link node IDs and
|
||||
* SubgraphNode `properties.proxyWidgets` references so promoted widget
|
||||
* associations stay aligned with remapped interior IDs.
|
||||
* node IDs in the root graph. Also patches subgraph link node IDs and legacy
|
||||
* SubgraphNode `properties.proxyWidgets` references so migration input stays
|
||||
* aligned with remapped interior IDs until the ADR 0009 flush consumes it.
|
||||
*/
|
||||
export function remapClipboardSubgraphNodeIds(
|
||||
parsed: ClipboardItems,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
@@ -206,39 +204,4 @@ describe('SubgraphConversion', () => {
|
||||
expect(linkRefCount).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Promotion cleanup on unpack', () => {
|
||||
it('Should clear promotions for the unpacked subgraph node', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraphNode.graph!
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const innerNode = createNode(subgraph, [], ['number'])
|
||||
innerNode.addWidget('text', 'myWidget', 'default', () => {})
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
const graphId = graph.id
|
||||
const subgraphNodeId = subgraphNode.id
|
||||
|
||||
promotionStore.promote(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
|
||||
expect(
|
||||
promotionStore.isPromoted(graphId, subgraphNodeId, {
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'myWidget'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
|
||||
expect(
|
||||
promotionStore.getPromotions(graphId, subgraphNodeId)
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
|
||||
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -491,15 +490,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toEqual([
|
||||
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
897
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
897
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Tests for SubgraphNode.serialize() after ADR 0009.
|
||||
*
|
||||
* Covers:
|
||||
* - Removed copy-back loop: exterior promoted host value does NOT mutate
|
||||
* the corresponding interior widget value.
|
||||
* - properties.proxyWidgets is no longer re-emitted on serialize.
|
||||
* - properties.previewExposures round-trip through the
|
||||
* PreviewExposureStore.
|
||||
* - properties.proxyWidgetErrorQuarantine round-trips and is inert at
|
||||
* runtime; an empty quarantine is omitted from the serialized payload.
|
||||
*/
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
appendHostQuarantine,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import type { ISlotType, TWidgetType } from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import {
|
||||
reorderSubgraphInputAtIndex,
|
||||
reorderSubgraphInputsByName
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetType: TWidgetType = 'number',
|
||||
widgetValue: unknown = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
// @ts-expect-error Abstract class instantiation
|
||||
const widget = new BaseWidget({
|
||||
name: 'widget',
|
||||
type: widgetType,
|
||||
value: widgetValue,
|
||||
y: 0,
|
||||
options: widgetType === 'number' ? { min: 0, max: 100, step: 1 } : {},
|
||||
node
|
||||
})
|
||||
node.widgets = [widget]
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
function expectPromotedWidgetView(
|
||||
widget: unknown
|
||||
): asserts widget is PromotedWidgetView {
|
||||
expect(widget).toMatchObject({
|
||||
sourceNodeId: expect.any(String),
|
||||
sourceWidgetName: expect.any(String)
|
||||
})
|
||||
}
|
||||
|
||||
function getHostStateName(widget: PromotedWidgetView): string {
|
||||
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
|
||||
}
|
||||
|
||||
describe('SubgraphNode.serialize (ADR 0009)', () => {
|
||||
describe('removed copy-back loop', () => {
|
||||
it('does not mutate interior widget values during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node: interiorNode, widget: interiorWidget } =
|
||||
createNodeWithWidget('Interior')
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const hostWidget = hostNode.widgets[0]
|
||||
expectPromotedWidgetView(hostWidget)
|
||||
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
|
||||
nodeId: hostNode.id,
|
||||
name: getHostStateName(hostWidget),
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
})
|
||||
|
||||
hostNode.serialize()
|
||||
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
})
|
||||
|
||||
it('does not mutate live properties while projecting store-owned serialization metadata', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.properties.previewExposures = [
|
||||
{
|
||||
name: 'stale',
|
||||
sourceNodeId: '0',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
hostNode.properties.proxyWidgetErrorQuarantine = []
|
||||
const livePropertiesBefore = structuredClone(hostNode.properties)
|
||||
|
||||
usePreviewExposureStore().addExposure(
|
||||
hostNode.rootGraph.id,
|
||||
String(hostNode.id),
|
||||
{
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
)
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
expect(hostNode.properties).toEqual(livePropertiesBefore)
|
||||
expect(serialized.properties?.previewExposures).toEqual([
|
||||
{
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
expect(serialized.properties?.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('host widget values', () => {
|
||||
it('serializes promoted values from each host independently', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node: interiorNode } = createNodeWithWidget('Interior')
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
|
||||
subgraph.rootGraph.add(firstHost)
|
||||
subgraph.rootGraph.add(secondHost)
|
||||
|
||||
firstHost.widgets[0].value = 111
|
||||
secondHost.widgets[0].value = 222
|
||||
|
||||
expect(firstHost.serialize().widgets_values).toEqual([111])
|
||||
expect(secondHost.serialize().widgets_values).toEqual([222])
|
||||
})
|
||||
|
||||
it('keeps promoted values attached to their inputs after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const first = createNodeWithWidget('First', 'number', 1)
|
||||
const second = createNodeWithWidget('Second', 'number', 2)
|
||||
subgraph.add(first.node)
|
||||
subgraph.add(second.node)
|
||||
|
||||
const firstInput = subgraph.addInput('first', 'number')
|
||||
firstInput.connect(first.input, first.node)
|
||||
const secondInput = subgraph.addInput('second', 'number')
|
||||
secondInput.connect(second.input, second.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.widgets[0].value = 111
|
||||
host.widgets[1].value = 222
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.widgets.map((widget) => widget.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([222, 111])
|
||||
})
|
||||
|
||||
it('does not persist source widget store fallback values after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const first = createNodeWithWidget('First', 'text', '', 'STRING')
|
||||
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
|
||||
subgraph.add(first.node)
|
||||
subgraph.add(second.node)
|
||||
|
||||
const firstInput = subgraph.addInput('first', 'STRING')
|
||||
firstInput.connect(first.input, first.node)
|
||||
const secondInput = subgraph.addInput('second', 'STRING')
|
||||
secondInput.connect(second.input, second.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: first.node.id,
|
||||
name: first.widget.name,
|
||||
type: first.widget.type,
|
||||
value: 'first value',
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: second.node.id,
|
||||
name: second.widget.name,
|
||||
type: second.widget.type,
|
||||
value: 'second value',
|
||||
options: {}
|
||||
})
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'STRING' }]
|
||||
})
|
||||
const { node: interiorNode, widget: interiorWidget } =
|
||||
createNodeWithWidget('Interior', 'text', '', 'STRING')
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: interiorNode.id,
|
||||
name: interiorWidget.name,
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
})
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
reloaded.configure(serialized)
|
||||
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toEqual([])
|
||||
expect(reloaded.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const first = createNodeWithWidget('First', 'text', '', 'STRING')
|
||||
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
|
||||
subgraph.add(first.node)
|
||||
subgraph.add(second.node)
|
||||
|
||||
const firstInput = subgraph.addInput('first', 'STRING')
|
||||
firstInput.connect(first.input, first.node)
|
||||
const secondInput = subgraph.addInput('second', 'STRING')
|
||||
secondInput.connect(second.input, second.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
host.widgets[1].value = 'second host value'
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toEqual([
|
||||
undefined,
|
||||
'second host value'
|
||||
])
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.clearGraph(host.rootGraph.id)
|
||||
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const firstReloadedWidget = reloaded.widgets[0]
|
||||
const secondReloadedWidget = reloaded.widgets[1]
|
||||
expectPromotedWidgetView(firstReloadedWidget)
|
||||
expectPromotedWidgetView(secondReloadedWidget)
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
reloaded.rootGraph.id,
|
||||
reloaded.id,
|
||||
getHostStateName(firstReloadedWidget)
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
reloaded.rootGraph.id,
|
||||
reloaded.id,
|
||||
getHostStateName(secondReloadedWidget)
|
||||
)?.value
|
||||
).toBe('second host value')
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
).toHaveLength(1)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([
|
||||
undefined,
|
||||
'second host value'
|
||||
])
|
||||
})
|
||||
|
||||
it('moves Vue-edited values with promoted widgets after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const first = createNodeWithWidget('First', 'text', '', 'STRING')
|
||||
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
|
||||
subgraph.add(first.node)
|
||||
subgraph.add(second.node)
|
||||
|
||||
const firstInput = subgraph.addInput('first', 'STRING')
|
||||
firstInput.connect(first.input, first.node)
|
||||
const secondInput = subgraph.addInput('second', 'STRING')
|
||||
secondInput.connect(second.input, second.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const nodeData = extractVueNodeData(host)
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
widgets[0].updateHandler('first value')
|
||||
widgets[1].updateHandler('second value')
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('sends Vue-edited values to dragged promoted widget targets', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const first = createNodeWithWidget('First', 'text', '', 'STRING')
|
||||
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
|
||||
first.node.comfyClass = 'First'
|
||||
second.node.comfyClass = 'Second'
|
||||
subgraph.add(first.node)
|
||||
subgraph.add(second.node)
|
||||
|
||||
const firstInput = subgraph.addInput('first', 'STRING')
|
||||
firstInput.connect(first.input, first.node)
|
||||
const secondInput = subgraph.addInput('second', 'STRING')
|
||||
secondInput.connect(second.input, second.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
const nodeData = extractVueNodeData(host)
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
widgets[0].updateHandler('first value')
|
||||
widgets[1].updateHandler('second value')
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
|
||||
expect(output[`${host.id}:${first.node.id}`].inputs.value).toBe(
|
||||
'first value'
|
||||
)
|
||||
expect(output[`${host.id}:${second.node.id}`].inputs.value).toBe(
|
||||
'second value'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps text and seed values on their targets when the seed input moves up', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
positive.node.comfyClass = 'Positive'
|
||||
seed.node.comfyClass = 'Sampler'
|
||||
negative.node.comfyClass = 'Negative'
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(seed.node)
|
||||
subgraph.add(negative.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
host.widgets[0].value = 'positive prompt'
|
||||
host.widgets[1].value = 'negative prompt'
|
||||
host.widgets[2].value = 123456
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'positive prompt',
|
||||
123456,
|
||||
'negative prompt'
|
||||
])
|
||||
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
|
||||
'positive prompt'
|
||||
)
|
||||
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
|
||||
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
|
||||
'negative prompt'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Vue-edited text and seed values on their targets when the seed input moves up', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
positive.node.comfyClass = 'Positive'
|
||||
negative.node.comfyClass = 'Negative'
|
||||
seed.node.comfyClass = 'Sampler'
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(negative.node)
|
||||
subgraph.add(seed.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
const nodeData = extractVueNodeData(host)
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
widgets[0].updateHandler('positive prompt')
|
||||
widgets[1].updateHandler('negative prompt')
|
||||
widgets[2].updateHandler(123456)
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'positive prompt',
|
||||
123456,
|
||||
'negative prompt'
|
||||
])
|
||||
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
|
||||
'positive prompt'
|
||||
)
|
||||
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
|
||||
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
|
||||
'negative prompt'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores direct source seed changes after the seed input moves up', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
positive.node.comfyClass = 'Positive'
|
||||
negative.node.comfyClass = 'Negative'
|
||||
seed.node.comfyClass = 'Sampler'
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(negative.node)
|
||||
subgraph.add(seed.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
host.widgets[0].value = 'positive prompt'
|
||||
host.widgets[1].value = 'negative prompt'
|
||||
host.widgets[2].value = 123456
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
|
||||
seed.widget.linkedWidgets = [
|
||||
{
|
||||
name: 'control_after_generate',
|
||||
value: 'increment',
|
||||
serialize: false,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {}
|
||||
} as never
|
||||
]
|
||||
seed.widget.value = 789
|
||||
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
|
||||
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
|
||||
})
|
||||
|
||||
it('syncs Vue-edited promoted seed values to the controlled source widget after moving seed up', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
seed.widget.options.max = 1125899906842624
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(negative.node)
|
||||
subgraph.add(seed.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
seed.widget.linkedWidgets = [
|
||||
{
|
||||
name: 'control_after_generate',
|
||||
value: 'fixed',
|
||||
serialize: false,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {}
|
||||
} as never
|
||||
]
|
||||
const nodeData = extractVueNodeData(host)
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
widgets[2].updateHandler(123456)
|
||||
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
|
||||
expect(seed.widget.value).toBe(123456)
|
||||
})
|
||||
|
||||
it('shows a control-updated promoted seed value in processed widgets after moving seed up', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
seed.widget.options.max = 1125899906842624
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(negative.node)
|
||||
subgraph.add(seed.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const nodeData = extractVueNodeData(host)
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
widgets[2].updateHandler(123456)
|
||||
seed.widget.linkedWidgets = [
|
||||
{
|
||||
name: 'control_after_generate',
|
||||
value: 'increment',
|
||||
serialize: false,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {},
|
||||
[IS_CONTROL_WIDGET]: true
|
||||
} as never
|
||||
]
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
host.widgets[1].afterQueued?.()
|
||||
|
||||
const updatedNodeData = extractVueNodeData(host)
|
||||
const updatedWidgets = computeProcessedWidgets({
|
||||
nodeData: updatedNodeData,
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: {
|
||||
getTooltipConfig: () => ({}),
|
||||
handleNodeRightClick: () => {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(updatedWidgets[1].value).toBe(123457)
|
||||
})
|
||||
|
||||
it('increments the promoted host seed without using the source seed value', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
|
||||
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
|
||||
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
|
||||
seed.widget.options.max = 1125899906842624
|
||||
subgraph.add(positive.node)
|
||||
subgraph.add(negative.node)
|
||||
subgraph.add(seed.node)
|
||||
|
||||
const positiveInput = subgraph.addInput('text_1', 'STRING')
|
||||
positiveInput.connect(positive.input, positive.node)
|
||||
const negativeInput = subgraph.addInput('text', 'STRING')
|
||||
negativeInput.connect(negative.input, negative.node)
|
||||
const seedInput = subgraph.addInput('seed', 'INT')
|
||||
seedInput.connect(seed.input, seed.node)
|
||||
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
seed.widget.linkedWidgets = [
|
||||
{
|
||||
name: 'control_after_generate',
|
||||
value: 'increment',
|
||||
serialize: false,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {},
|
||||
[IS_CONTROL_WIDGET]: true
|
||||
} as never
|
||||
]
|
||||
host.widgets[2].value = 2
|
||||
reorderSubgraphInputAtIndex(host, 2, 1)
|
||||
seed.widget.value = 8
|
||||
host.widgets[1].afterQueued?.()
|
||||
|
||||
expect(host.widgets[1].value).toBe(3)
|
||||
expect(
|
||||
useWidgetValueStore()
|
||||
.getNodeWidgets(host.rootGraph.id, host.id)
|
||||
.find((entry) => entry.name.startsWith('seed:'))?.value
|
||||
).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyWidgets is no longer re-emitted', () => {
|
||||
it('does not write properties.proxyWidgets after serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node: interiorNode } = createNodeWithWidget('Interior')
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
// Ensure no pre-existing proxyWidgets property leaks through.
|
||||
delete hostNode.properties.proxyWidgets
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toBeUndefined()
|
||||
expect(hostNode.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a pre-existing legacy proxyWidgets property without re-deriving it', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
const legacy: SerializedProxyWidgetTuple[] = [['7', 'seed']]
|
||||
hostNode.properties.proxyWidgets = legacy
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
// Still serialized as-is — not deleted, not rewritten.
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
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 = String(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)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const rootGraphId = hostNode.rootGraph.id
|
||||
const hostLocator = String(hostNode.id)
|
||||
|
||||
store.addExposure(rootGraphId, hostLocator, {
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(rootGraphId, hostLocator, {
|
||||
sourceNodeId: '14',
|
||||
sourcePreviewName: 'videopreview'
|
||||
})
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
expect(serialized.properties?.previewExposures).toEqual([
|
||||
{
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
},
|
||||
{
|
||||
name: 'videopreview',
|
||||
sourceNodeId: '14',
|
||||
sourcePreviewName: 'videopreview'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('serializes preview exposures per host instance', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
|
||||
subgraph.rootGraph.add(firstHost)
|
||||
subgraph.rootGraph.add(secondHost)
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const rootGraphId = firstHost.rootGraph.id
|
||||
|
||||
store.addExposure(rootGraphId, String(firstHost.id), {
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(rootGraphId, String(secondHost.id), {
|
||||
sourceNodeId: '14',
|
||||
sourcePreviewName: 'videopreview'
|
||||
})
|
||||
|
||||
const firstExposures = firstHost.serialize().properties?.previewExposures
|
||||
const secondExposures =
|
||||
secondHost.serialize().properties?.previewExposures
|
||||
|
||||
expect(Array.isArray(firstExposures)).toBe(true)
|
||||
expect(Array.isArray(secondExposures)).toBe(true)
|
||||
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures))
|
||||
throw new Error('Expected serialized previewExposures arrays')
|
||||
|
||||
expect(firstExposures).toEqual([
|
||||
{
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '12',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
expect(secondExposures).toEqual([
|
||||
{
|
||||
name: 'videopreview',
|
||||
sourceNodeId: '14',
|
||||
sourcePreviewName: 'videopreview'
|
||||
}
|
||||
])
|
||||
expect(firstExposures?.[0]).not.toHaveProperty('hostInstanceId')
|
||||
expect(firstExposures?.[0]).not.toHaveProperty('hostNodeLocator')
|
||||
expect(firstExposures?.[0]).not.toHaveProperty('rootGraphId')
|
||||
expect(secondExposures?.[0]).not.toHaveProperty('hostInstanceId')
|
||||
expect(secondExposures?.[0]).not.toHaveProperty('hostNodeLocator')
|
||||
expect(secondExposures?.[0]).not.toHaveProperty('rootGraphId')
|
||||
})
|
||||
|
||||
it('omits previewExposures when the store has no entries for the host', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.properties.previewExposures = [
|
||||
{
|
||||
name: 'stale',
|
||||
sourceNodeId: '0',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
expect(serialized.properties?.previewExposures).toBeUndefined()
|
||||
expect(hostNode.properties.previewExposures).toEqual([
|
||||
{
|
||||
name: 'stale',
|
||||
sourceNodeId: '0',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyWidgetErrorQuarantine', () => {
|
||||
it('preserves quarantine entries through serialize and is inert at runtime', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
appendHostQuarantine(hostNode, [
|
||||
makeQuarantineEntry({
|
||||
originalEntry: ['7', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 42
|
||||
})
|
||||
])
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
const quarantine = serialized.properties?.proxyWidgetErrorQuarantine
|
||||
expect(Array.isArray(quarantine)).toBe(true)
|
||||
expect(quarantine).toHaveLength(1)
|
||||
|
||||
// Inertness: quarantine entries do not produce widgets.
|
||||
expect(
|
||||
hostNode.widgets.some(
|
||||
(w) => 'sourceNodeId' in w && w.sourceNodeId === '7'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the property entirely when quarantine is empty', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.properties.proxyWidgetErrorQuarantine = []
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
expect(serialized.properties?.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostNode.properties.proxyWidgetErrorQuarantine).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,27 +28,27 @@ import type {
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createPromotedWidgetView,
|
||||
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 { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
makePromotionEntryKey,
|
||||
usePromotionStore
|
||||
} from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -70,6 +70,14 @@ type LinkedPromotionEntry = PromotedWidgetSource & {
|
||||
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
|
||||
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
|
||||
|
||||
function isWidgetValue(value: unknown): value is TWidgetValue {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
if (typeof value === 'number') return true
|
||||
if (typeof value === 'boolean') return true
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
/**
|
||||
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
|
||||
*/
|
||||
@@ -96,22 +104,16 @@ 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
|
||||
inputOrderKey: string
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
entries: LinkedPromotionEntry[]
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
entriesRef: PromotedWidgetSource[]
|
||||
inputOrderKey: string
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
@@ -142,15 +144,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
if (isPromotedWidgetView(targetWidget)) {
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetWidget.sourceWidgetName,
|
||||
disambiguatingSourceNodeId:
|
||||
targetWidget.disambiguatingSourceNodeId ??
|
||||
targetWidget.sourceNodeId
|
||||
}
|
||||
}
|
||||
// ADR 0009: each SubgraphNode is opaque. The promoted source on the
|
||||
// parent host always references the immediate child's input slot, not
|
||||
// the deeper leaf widget identity that the child internally exposes.
|
||||
return {
|
||||
sourceNodeId: String(inputNode.id),
|
||||
sourceWidgetName: targetInput.name
|
||||
@@ -166,10 +162,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
const inputOrderKey = this._getInputOrderKey()
|
||||
const cached = this._linkedEntriesCache
|
||||
if (
|
||||
cache &&
|
||||
cached?.version === this._cacheVersion &&
|
||||
cached.inputOrderKey === inputOrderKey &&
|
||||
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
|
||||
)
|
||||
return cached.entries
|
||||
@@ -220,8 +218,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
entry.inputName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
@@ -232,6 +229,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (cache)
|
||||
this._linkedEntriesCache = {
|
||||
version: this._cacheVersion,
|
||||
inputOrderKey,
|
||||
hasMissingBoundSourceWidget,
|
||||
entries: deduplicatedEntries
|
||||
}
|
||||
@@ -257,21 +255,19 @@ 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 inputOrderKey = this._getInputOrderKey()
|
||||
const cachedViews = this._promotedViewsCache
|
||||
if (
|
||||
cachedViews?.version === this._cacheVersion &&
|
||||
cachedViews.entriesRef === entries &&
|
||||
cachedViews.inputOrderKey === inputOrderKey &&
|
||||
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,
|
||||
@@ -281,14 +277,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
|
||||
entry.disambiguatingSourceNodeId,
|
||||
entry.slotName
|
||||
)
|
||||
)
|
||||
|
||||
this._promotedViewsCache = {
|
||||
version: this._cacheVersion,
|
||||
entriesRef: entries,
|
||||
inputOrderKey,
|
||||
hasMissingBoundSourceWidget,
|
||||
views
|
||||
}
|
||||
@@ -296,303 +291,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return views
|
||||
}
|
||||
|
||||
private _getInputOrderKey(): string {
|
||||
return this.inputs
|
||||
.map((input) => input._subgraphSlot?.id ?? input.name)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
private _invalidatePromotedViewsCache(): void {
|
||||
this._cacheVersion++
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private _buildLinkedReconcileEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
viewKey: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
slotName: string
|
||||
}> {
|
||||
return linkedEntries.map(
|
||||
({
|
||||
inputKey,
|
||||
inputName,
|
||||
slotName,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
}) => ({
|
||||
({ inputKey, inputName, slotName, sourceNodeId, sourceWidgetName }) => ({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
slotName,
|
||||
disambiguatingSourceNodeId,
|
||||
viewKey: this._makePromotionViewKey(
|
||||
inputKey,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
inputName,
|
||||
disambiguatingSourceNodeId
|
||||
inputName
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -607,77 +333,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
entry.inputKey,
|
||||
entry.sourceNodeId,
|
||||
entry.sourceWidgetName,
|
||||
entry.inputName,
|
||||
entry.disambiguatingSourceNodeId
|
||||
entry.inputName
|
||||
),
|
||||
entry.inputName
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
private _makePromotionEntryKey(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): string {
|
||||
return makePromotionEntryKey({
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputKey: string,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
inputName = '',
|
||||
disambiguatingSourceNodeId?: string
|
||||
inputName = ''
|
||||
): string {
|
||||
return disambiguatingSourceNodeId
|
||||
? JSON.stringify([
|
||||
inputKey,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
inputName,
|
||||
disambiguatingSourceNodeId
|
||||
])
|
||||
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
|
||||
private _serializeEntries(
|
||||
entries: PromotedWidgetSource[]
|
||||
): (string[] | [string, string, string])[] {
|
||||
return entries.map((e) =>
|
||||
e.disambiguatingSourceNodeId
|
||||
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
|
||||
: [e.sourceNodeId, e.sourceWidgetName]
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
|
||||
}
|
||||
|
||||
/** Manages lifecycle of all subgraph event listeners */
|
||||
@@ -762,7 +431,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._syncPromotions()
|
||||
this.setDirtyCanvas(true, true)
|
||||
},
|
||||
{ signal }
|
||||
@@ -874,17 +542,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
|
||||
@@ -907,7 +564,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
e.detail.node
|
||||
)
|
||||
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -922,7 +579,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
if (connectedWidgets.length > 0) {
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -931,7 +588,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
delete input.pos
|
||||
delete input.widget
|
||||
input._widget = undefined
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -1043,6 +700,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
|
||||
super.configure(info)
|
||||
this._applyPromotedWidgetValues(info.widgets_values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate per-instance promoted widget values into this host's widget value
|
||||
* store entry. Routing through `PromotedWidgetView.set value` would cascade
|
||||
* into the shared interior widget, stomping every other SubgraphNode
|
||||
* instance that references the same shared interior.
|
||||
*/
|
||||
private _applyPromotedWidgetValues(
|
||||
widgetValues: ExportedSubgraphInstance['widgets_values']
|
||||
): void {
|
||||
if (!widgetValues) return
|
||||
// Transient clones created during clipboard duplicate go through
|
||||
// configure() with `id === -1` before being added to the graph.
|
||||
// Hydrating under id `-1` would poison `useWidgetValueStore` and
|
||||
// race with the eventual real instance for ownership of host state.
|
||||
if (this.id === -1) return
|
||||
|
||||
let valueIndex = 0
|
||||
for (const input of this.inputs) {
|
||||
const view = input._widget
|
||||
if (!view || !isPromotedWidgetView(view)) continue
|
||||
if (valueIndex >= widgetValues.length) return
|
||||
const value = widgetValues[valueIndex]
|
||||
if (value !== undefined) view.hydrateHostValue(value)
|
||||
valueIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
@@ -1052,50 +737,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// This prevents stale/duplicate serialized inputs from persisting (#9977).
|
||||
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
|
||||
|
||||
// Ensure proxyWidgets is initialized so it serializes
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
// Clear view cache — forces re-creation on next getter access.
|
||||
// Do NOT clear properties.proxyWidgets — it was already populated
|
||||
// from serialized data by super.configure(info) before this runs.
|
||||
this._promotedViewManager.clear()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// Hydrate the store from serialized properties.proxyWidgets
|
||||
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)
|
||||
|
||||
// Write back resolved entries so legacy or stale entries don't persist
|
||||
const serialized = this._serializeEntries(entries)
|
||||
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
|
||||
this.properties.proxyWidgets = serialized
|
||||
}
|
||||
usePreviewExposureStore().setExposures(
|
||||
this.rootGraph.id,
|
||||
String(this.id),
|
||||
parsePreviewExposures(this.properties.previewExposures)
|
||||
)
|
||||
|
||||
// Check all inputs for connected widgets
|
||||
for (const input of this.inputs) {
|
||||
@@ -1113,16 +763,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
}
|
||||
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
for (const node of this.subgraph.nodes) {
|
||||
if (!supportsVirtualCanvasImagePreview(node)) continue
|
||||
const source: PromotedWidgetSource = {
|
||||
const hostLocator = String(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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1144,7 +802,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
}
|
||||
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
}
|
||||
|
||||
private _resolveInputWidget(
|
||||
@@ -1202,14 +860,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
interiorNode: LGraphNode
|
||||
) {
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._flushPendingPromotions()
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
const widgetName = interiorWidget.name
|
||||
const sourceNodeId =
|
||||
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
|
||||
? interiorWidget.sourceNodeId
|
||||
: undefined
|
||||
|
||||
const previousView = input._widget
|
||||
|
||||
@@ -1219,34 +872,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
|
||||
@@ -1260,15 +888,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name,
|
||||
sourceNodeId,
|
||||
subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name,
|
||||
sourceNodeId
|
||||
input.label ?? input.name
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1291,19 +917,8 @@ 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()
|
||||
this._invalidatePromotedViewsCache()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1454,8 +1069,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const resolved = resolveConcretePromotedWidget(
|
||||
this,
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
view.disambiguatingSourceNodeId
|
||||
view.sourceWidgetName
|
||||
)
|
||||
if (resolved.status !== 'resolved') return
|
||||
|
||||
@@ -1484,8 +1098,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName,
|
||||
view.disambiguatingSourceNodeId
|
||||
inputName
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1498,7 +1111,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) {
|
||||
@@ -1512,7 +1124,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphNode: this
|
||||
})
|
||||
|
||||
this._syncPromotions()
|
||||
this._invalidatePromotedViewsCache()
|
||||
}
|
||||
|
||||
override onRemoved(): void {
|
||||
@@ -1529,7 +1141,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
})
|
||||
}
|
||||
|
||||
usePromotionStore().setPromotions(this.rootGraph.id, this.id, [])
|
||||
this._promotedViewManager.clear()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
@@ -1574,36 +1185,72 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
* Serializes this SubgraphNode instance.
|
||||
*
|
||||
* After ADR 0009 the canonical owner of each promoted value widget is the
|
||||
* linked `SubgraphInput` itself; host-overlay values live in
|
||||
* `widgets_values`, and previews live in `properties.previewExposures`.
|
||||
* `properties.proxyWidgets` is no longer re-emitted; legacy data is preserved
|
||||
* for one-way ratchet load only.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
// TODO(adr-0009): Remove this comment once one stable release has shipped
|
||||
// without complaints about subgraph value drift. Host promoted-widget
|
||||
// values now serialize through standard SubgraphInput widgets and must not
|
||||
// be copied into interior widgets, which would cause cross-host stomping.
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
const serialized = super.serialize()
|
||||
const serializedProperties = { ...(serialized.properties ?? {}) }
|
||||
const rootGraphId = this.rootGraph.id
|
||||
const hostLocator = String(this.id)
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
const previewExposures = usePreviewExposureStore().getExposures(
|
||||
rootGraphId,
|
||||
hostLocator
|
||||
)
|
||||
if (previewExposures.length > 0) {
|
||||
serializedProperties.previewExposures = previewExposures.map((entry) => ({
|
||||
...entry
|
||||
}))
|
||||
} else {
|
||||
delete serializedProperties.previewExposures
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
this.id
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
const quarantine = readHostQuarantine(this)
|
||||
if (quarantine.length === 0) {
|
||||
delete serializedProperties.proxyWidgetErrorQuarantine
|
||||
} else {
|
||||
serializedProperties.proxyWidgetErrorQuarantine = quarantine.map(
|
||||
(entry) => ({ ...entry })
|
||||
)
|
||||
}
|
||||
|
||||
return super.serialize()
|
||||
serialized.properties = serializedProperties
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const widgetValues: TWidgetValue[] = []
|
||||
let hasSerializableValue = false
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) continue
|
||||
const state = widgetStore.getWidget(
|
||||
rootGraphId,
|
||||
this.id,
|
||||
getPromotedWidgetHostStateName(widget)
|
||||
)
|
||||
const value =
|
||||
state && isWidgetValue(state.value) ? state.value : undefined
|
||||
widgetValues.push(value)
|
||||
hasSerializableValue ||= value !== undefined
|
||||
}
|
||||
|
||||
if (hasSerializableValue) serialized.widgets_values = widgetValues
|
||||
else delete serialized.widgets_values
|
||||
|
||||
return serialized
|
||||
}
|
||||
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
import {
|
||||
@@ -291,9 +292,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(interiorNode.id), 'batch_size']
|
||||
])
|
||||
// ADR 0009: configure() no longer writes resolved entries back to
|
||||
// properties.proxyWidgets. Hydration is observable via the synthetic
|
||||
// widget surface instead.
|
||||
expect(hostNode.widgets).toHaveLength(1)
|
||||
expect(hostNode.widgets[0].name).toBe('batch_size')
|
||||
expect(hostNode.widgets[0].value).toBe(1)
|
||||
@@ -356,7 +357,14 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
expect(widgetSourceIds).toContain(keptSamplerNodeId)
|
||||
})
|
||||
|
||||
it('should normalize legacy prefixed proxyWidgets on configure', () => {
|
||||
it('quarantines legacy prefixed proxyWidgets that target a deep leaf widget', () => {
|
||||
// ADR 0009: each SubgraphNode is opaque. The legacy
|
||||
// "<nestedId>: <leafId>: <leafWidgetName>" encoding referenced a deep
|
||||
// leaf widget through nested chain traversal. Under the opaque model
|
||||
// the migration cannot resolve that identity at the immediate level,
|
||||
// so the entry is quarantined rather than reconstructed as a
|
||||
// canonical promoted view. Users with this legacy state must
|
||||
// re-promote through each subgraph level explicitly.
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
@@ -396,21 +404,16 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
}
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
flushProxyWidgetMigration({ hostNode })
|
||||
|
||||
const promotedWidgets = hostNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
|
||||
expect(promotedWidgets).toHaveLength(1)
|
||||
expect(promotedWidgets[0].type).toBe('number')
|
||||
expect(promotedWidgets[0].value).toBe(123)
|
||||
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
|
||||
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
|
||||
String(samplerNode.id)
|
||||
)
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
|
||||
])
|
||||
expect(promotedWidgets).toHaveLength(0)
|
||||
expect(hostNode.properties.proxyWidgets).toBeUndefined()
|
||||
const quarantine = hostNode.properties.proxyWidgetErrorQuarantine
|
||||
expect(Array.isArray(quarantine) && quarantine.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should preserve promoted widget entries after cloning', () => {
|
||||
@@ -427,31 +430,21 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
// serialize() syncs the promotion store into properties.proxyWidgets
|
||||
const serialized = hostNode.serialize()
|
||||
const originalProxyWidgets = serialized.properties!
|
||||
.proxyWidgets as string[][]
|
||||
|
||||
expect(originalProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
|
||||
// Simulate clone: create a second SubgraphNode configured from serialized data
|
||||
// ADR 0009: clone preservation no longer relies on properties.proxyWidgets.
|
||||
// The promoted widgets are derived from the linked SubgraphInputs that
|
||||
// come through the serialized inputs/links, so the host's own widgets
|
||||
// getter should expose the promoted view after configure.
|
||||
const cloneNode = createTestSubgraphNode(subgraph)
|
||||
cloneNode.configure(serialized)
|
||||
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
|
||||
|
||||
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
const promotedNames = cloneNode.widgets
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((widget) => !widget.name.startsWith('$$'))
|
||||
.map((widget) => widget.sourceWidgetName)
|
||||
|
||||
// Clone's proxyWidgets should reference the same interior node
|
||||
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
|
||||
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
|
||||
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
|
||||
expect(promotedNames).toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,15 +19,16 @@ interface DeduplicationResult {
|
||||
* they are configured. This prevents widget store key collisions when
|
||||
* multiple subgraph copies contain nodes with the same IDs.
|
||||
*
|
||||
* Also patches proxyWidgets in root-level nodes that reference the
|
||||
* remapped inner node IDs.
|
||||
* Also patches legacy proxyWidgets in root-level nodes that reference the
|
||||
* remapped inner node IDs. The ADR 0009 migration flush consumes these tuples
|
||||
* after configure.
|
||||
*
|
||||
* Returns deep clones of the inputs — the originals are never mutated.
|
||||
*
|
||||
* @param subgraphs - Serialized subgraph definitions to deduplicate
|
||||
* @param reservedNodeIds - Node IDs already in use by root-level nodes
|
||||
* @param state - Graph state containing the `lastNodeId` counter (mutated)
|
||||
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
|
||||
* @param rootNodes - Optional root-level nodes with legacy proxyWidgets to patch
|
||||
*/
|
||||
export function deduplicateSubgraphNodeIds(
|
||||
subgraphs: ExportedSubgraph[],
|
||||
@@ -197,7 +198,7 @@ export function topologicalSortSubgraphs(
|
||||
return sorted
|
||||
}
|
||||
|
||||
/** Patches proxyWidgets in root-level SubgraphNode instances. */
|
||||
/** Patches legacy proxyWidgets in root-level SubgraphNode instances. */
|
||||
function patchProxyWidgets(
|
||||
rootNodes: ISerialisedNode[],
|
||||
subgraphIdSet: Set<string>,
|
||||
|
||||
53
src/lib/litegraph/src/subgraph/subgraphMigrationHook.test.ts
Normal file
53
src/lib/litegraph/src/subgraph/subgraphMigrationHook.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
runSubgraphMigrationFlushHook,
|
||||
setSubgraphMigrationFlushHook
|
||||
} from './subgraphMigrationHook'
|
||||
|
||||
describe('subgraph migration hook registry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setSubgraphMigrationFlushHook(undefined)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('warns in tests when legacy proxyWidgets exist but no flush hook is wired', () => {
|
||||
const hostNode = createTestSubgraphNode(createTestSubgraph())
|
||||
hostNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
runSubgraphMigrationFlushHook(hostNode, undefined)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
|
||||
expect.objectContaining({
|
||||
hostNodeId: hostNode.id,
|
||||
proxyWidgets: [['1', 'seed']]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the wired flush hook instead of warning', () => {
|
||||
const hostNode = createTestSubgraphNode(createTestSubgraph())
|
||||
hostNode.properties.proxyWidgets = [['1', 'seed']]
|
||||
const hook = vi.fn()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
setSubgraphMigrationFlushHook(hook)
|
||||
|
||||
runSubgraphMigrationFlushHook(hostNode, undefined)
|
||||
|
||||
expect(hook).toHaveBeenCalledWith({ hostNode, nodeData: undefined })
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
52
src/lib/litegraph/src/subgraph/subgraphMigrationHook.ts
Normal file
52
src/lib/litegraph/src/subgraph/subgraphMigrationHook.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
|
||||
/**
|
||||
* Late-bound hook that runs after a host graph has finished configuring all
|
||||
* its nodes and links. Wired in app initialization to {@link
|
||||
* flushProxyWidgetMigration}; left undefined in tests that exercise
|
||||
* `LGraph.configure` without the migration pipeline.
|
||||
*
|
||||
* The hook is intentionally untyped at the LGraph layer because importing
|
||||
* the flush directly from LGraph would create a circular dependency through
|
||||
* the PreviewExposureStore.
|
||||
*/
|
||||
type SubgraphMigrationFlushHook = (args: {
|
||||
hostNode: SubgraphNode
|
||||
nodeData: ISerialisedNode | undefined
|
||||
}) => void
|
||||
|
||||
interface SubgraphMigrationRegistry {
|
||||
flush?: SubgraphMigrationFlushHook
|
||||
}
|
||||
|
||||
const registry: SubgraphMigrationRegistry = {}
|
||||
|
||||
export function setSubgraphMigrationFlushHook(
|
||||
hook: SubgraphMigrationFlushHook | undefined
|
||||
): void {
|
||||
registry.flush = hook
|
||||
}
|
||||
|
||||
export function runSubgraphMigrationFlushHook(
|
||||
hostNode: SubgraphNode,
|
||||
nodeData: ISerialisedNode | undefined
|
||||
): void {
|
||||
if (registry.flush) {
|
||||
registry.flush({ hostNode, nodeData })
|
||||
return
|
||||
}
|
||||
|
||||
if (hostNode.properties.proxyWidgets === undefined) return
|
||||
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
|
||||
console.warn(
|
||||
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
|
||||
{
|
||||
hostNodeId: hostNode.id,
|
||||
proxyWidgets: hostNode.properties.proxyWidgets
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createApp } from 'vue'
|
||||
import { VueFire, VueFireAuth } from 'vuefire'
|
||||
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -108,6 +109,10 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
// ADR 0009: hook the proxyWidget migration flush into LGraph.configure.
|
||||
// Late-bound so the LGraph layer doesn't import the PreviewExposureStore.
|
||||
wireProxyWidgetMigrationFlush()
|
||||
|
||||
const bootstrapStore = useBootstrapStore(pinia)
|
||||
void bootstrapStore.startStoreBootstrap()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
|
||||
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
|
||||
import { resultItemType } from '@/schemas/resultItemTypeSchema'
|
||||
import type { ResultItemType } from '@/schemas/resultItemTypeSchema'
|
||||
import { zKeybinding } from '@/platform/keybindings/types'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
@@ -10,8 +12,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
|
||||
const zNodeType = z.string()
|
||||
const zJobId = z.string()
|
||||
export type JobId = z.infer<typeof zJobId>
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
export { resultItemType }
|
||||
export type { ResultItemType }
|
||||
|
||||
const zCustomNodesI18n = z.record(z.string(), z.unknown())
|
||||
export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
const nodeSlotSchema = z.object({
|
||||
CLIP: z.string(),
|
||||
@@ -32,9 +32,9 @@ const litegraphBaseSchema = z.object({
|
||||
NODE_DEFAULT_BGCOLOR: z.string(),
|
||||
NODE_DEFAULT_BOXCOLOR: z.string(),
|
||||
NODE_DEFAULT_SHAPE: z.union([
|
||||
z.literal(LiteGraph.BOX_SHAPE),
|
||||
z.literal(LiteGraph.ROUND_SHAPE),
|
||||
z.literal(LiteGraph.CARD_SHAPE),
|
||||
z.literal(RenderShape.BOX),
|
||||
z.literal(RenderShape.ROUND),
|
||||
z.literal(RenderShape.CARD),
|
||||
// Legacy palettes have string field for NODE_DEFAULT_SHAPE.
|
||||
z.string()
|
||||
]),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { resultItemType } from '@/schemas/resultItemTypeSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
|
||||
4
src/schemas/resultItemTypeSchema.ts
Normal file
4
src/schemas/resultItemTypeSchema.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const resultItemType = z.enum(['input', 'output', 'temp'])
|
||||
export type ResultItemType = z.infer<typeof resultItemType>
|
||||
1
src/scripts/controlWidgetMarker.ts
Normal file
1
src/scripts/controlWidgetMarker.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const IS_CONTROL_WIDGET = Symbol()
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@ import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import type { ComfyApp } from './app'
|
||||
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
|
||||
import './domWidget'
|
||||
import './errorNodeWidgets'
|
||||
|
||||
export { IS_CONTROL_WIDGET }
|
||||
|
||||
export type ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpecV2
|
||||
@@ -77,7 +80,6 @@ export function updateControlWidgetLabel(widget: IBaseWidget) {
|
||||
}
|
||||
}
|
||||
|
||||
export const IS_CONTROL_WIDGET = Symbol()
|
||||
const HAS_EXECUTED = Symbol()
|
||||
|
||||
export function addValueControlWidget(
|
||||
@@ -175,6 +177,14 @@ export function addValueControlWidgets(
|
||||
}
|
||||
|
||||
const applyWidgetControl = () => {
|
||||
if (
|
||||
node.inputs?.some(
|
||||
(input) =>
|
||||
input.widget?.name === targetWidget.name && input.link != null
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
var v = valueControl.value
|
||||
|
||||
if (isCombo && v !== 'fixed') {
|
||||
|
||||
@@ -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'
|
||||
@@ -187,11 +187,13 @@ export const useLitegraphService = () => {
|
||||
}
|
||||
|
||||
function getPseudoWidgetPreviewTargets(node: SubgraphNode): LGraphNode[] {
|
||||
const promotionStore = usePromotionStore()
|
||||
const promotions = promotionStore.getPromotionsRef(
|
||||
node.rootGraph.id,
|
||||
node.id
|
||||
)
|
||||
const hostLocator = String(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,
|
||||
|
||||
@@ -5,12 +5,14 @@ import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
|
||||
@@ -506,4 +508,138 @@ describe('appModeStore', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ADR 0009: legacy `(sourceNodeId, sourceWidgetName)` selection tuples
|
||||
// for promoted widgets must project through the wrapping host SubgraphNode
|
||||
// into `(hostNodeLocator, subgraphInputName)`. Tuples that cannot be
|
||||
// projected are dropped with `console.warn`.
|
||||
describe('legacy selectedInput tuple migration (ADR 0009)', () => {
|
||||
it('migrates legacy promoted widget selected inputs before node-existence passthrough', () => {
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const hostId = 5
|
||||
const sourceNodeId = 42
|
||||
const subgraphInputName = 'Prompt'
|
||||
const sourceWidgetName = 'text'
|
||||
const hostWidget = {
|
||||
name: subgraphInputName,
|
||||
sourceNodeId: String(sourceNodeId),
|
||||
sourceWidgetName
|
||||
}
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: hostId,
|
||||
inputs: [{ name: subgraphInputName, _widget: hostWidget }],
|
||||
widgets: [hostWidget],
|
||||
isSubgraphNode: () => true
|
||||
}) as SubgraphNode
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
|
||||
)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == sourceNodeId
|
||||
? fromAny<LGraphNode, unknown>({ id: sourceNodeId })
|
||||
: undefined
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([
|
||||
[
|
||||
createNodeLocatorId(rootGraphId, hostId),
|
||||
subgraphInputName,
|
||||
{ height: 120 }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a direct root-node widget when its id and name collide with a promoted source', () => {
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const hostId = 5
|
||||
const sourceNodeId = 42
|
||||
const sourceWidgetName = 'text'
|
||||
const rootNode = fromAny<LGraphNode, unknown>({
|
||||
id: sourceNodeId,
|
||||
widgets: [{ name: sourceWidgetName }]
|
||||
})
|
||||
const hostWidget = {
|
||||
name: 'Prompt',
|
||||
sourceNodeId: String(sourceNodeId),
|
||||
sourceWidgetName
|
||||
}
|
||||
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
|
||||
id: hostId,
|
||||
inputs: [{ name: 'Prompt', _widget: hostWidget }],
|
||||
widgets: [hostWidget],
|
||||
isSubgraphNode: () => true
|
||||
}) as SubgraphNode
|
||||
|
||||
vi.mocked(app.rootGraph).id = rootGraphId
|
||||
vi.mocked(app.rootGraph).nodes = [rootNode, hostNode]
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: NodeId | null | undefined) =>
|
||||
id == sourceNodeId ? rootNode : id == hostId ? hostNode : null
|
||||
)
|
||||
mockResolveNode.mockImplementation((id) =>
|
||||
id == sourceNodeId ? rootNode : undefined
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([
|
||||
[sourceNodeId, sourceWidgetName, { height: 120 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('warns and drops a tuple whose source node no longer resolves', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockResolveNode.mockReturnValue(undefined)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[42 as NodeId, 'widget-name', { height: 42 }]],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('legacy selectedInput tuple'),
|
||||
expect.objectContaining({
|
||||
storedId: 42,
|
||||
widgetName: 'widget-name'
|
||||
})
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes through tuples already in `hostLocator:subgraphInputName` form', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const hostId = 5
|
||||
const hostLocator = '11111111-1111-4111-8111-111111111111:5'
|
||||
const hostNode = fromAny<LGraphNode, unknown>({
|
||||
id: hostId,
|
||||
isSubgraphNode: () => true,
|
||||
widgets: [{ name: 'subgraph_input_name' }]
|
||||
})
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
|
||||
)
|
||||
|
||||
const result = store.pruneLinearData({
|
||||
inputs: [[hostLocator, 'subgraph_input_name']],
|
||||
outputs: []
|
||||
})
|
||||
|
||||
expect(result.inputs).toEqual([[hostLocator, 'subgraph_input_name']])
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
InputWidgetConfig,
|
||||
LinearData,
|
||||
@@ -18,7 +19,8 @@ import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { resolveNode, resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
export function nodeTypeValidForApp(type: string) {
|
||||
return !['Note', 'MarkdownNote'].includes(type)
|
||||
@@ -46,13 +48,20 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
// ADR 0009: also performs the one-shot legacy-tuple migration that
|
||||
// projects pre-ratchet `(sourceNodeId, sourceWidgetName)` selections
|
||||
// through the new host-scoped `(hostNodeLocator, subgraphInputName)`
|
||||
// identity. Failed projections are dropped with `console.warn`.
|
||||
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
|
||||
const rawInputs = data?.inputs ?? []
|
||||
const rawOutputs = data?.outputs ?? []
|
||||
|
||||
return {
|
||||
inputs: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
? rawInputs
|
||||
.map(migrateLegacyInputTuple)
|
||||
.filter((entry): entry is LinearInput => entry !== null)
|
||||
.filter(selectedInputExists)
|
||||
: rawInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
@@ -60,6 +69,86 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function selectedInputExists([nodeId, widgetName]: LinearInput): boolean {
|
||||
if (typeof nodeId === 'string' && nodeId.includes(':')) {
|
||||
if (typeof app.rootGraph?.getNodeById !== 'function') return true
|
||||
const [, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return Boolean(widget)
|
||||
}
|
||||
return Boolean(resolveNode(nodeId))
|
||||
}
|
||||
|
||||
/**
|
||||
* If a legacy tuple references the interior `(sourceNodeId, widgetName)`
|
||||
* of a now-promoted widget, project it through the wrapping host
|
||||
* SubgraphNode's locator + subgraph-input name.
|
||||
*/
|
||||
function migrateLegacyInputTuple(input: LinearInput): LinearInput | null {
|
||||
const [storedId, widgetName] = input
|
||||
if (typeof storedId === 'string' && storedId.includes(':')) {
|
||||
// Already in `(hostNodeLocator, subgraphInputName)` form.
|
||||
return input
|
||||
}
|
||||
|
||||
if (directRootWidgetExists(storedId, widgetName)) return input
|
||||
|
||||
const projection = projectLegacyTupleThroughHost(storedId, widgetName)
|
||||
if (projection) {
|
||||
return [projection.hostLocator, projection.subgraphInputName, input[2]]
|
||||
}
|
||||
|
||||
if (resolveNode(storedId)) return input
|
||||
|
||||
console.warn(
|
||||
'[appModeStore] dropping legacy selectedInput tuple — no canonical identity available',
|
||||
{ storedId, widgetName }
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function directRootWidgetExists(nodeId: NodeId, widgetName: string): boolean {
|
||||
const node = app.rootGraph?.getNodeById?.(nodeId)
|
||||
return Boolean(node?.widgets?.some((widget) => widget.name === widgetName))
|
||||
}
|
||||
|
||||
function projectLegacyTupleThroughHost(
|
||||
legacySourceNodeId: NodeId,
|
||||
legacyWidgetName: string
|
||||
): { hostLocator: string; subgraphInputName: string } | null {
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return null
|
||||
|
||||
const matches: Array<{ hostLocator: string; subgraphInputName: string }> =
|
||||
[]
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (!(node instanceof SubgraphNode)) continue
|
||||
|
||||
for (const inputSlot of node.inputs) {
|
||||
const widget = inputSlot._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) continue
|
||||
if (
|
||||
widget.sourceNodeId === String(legacySourceNodeId) &&
|
||||
widget.sourceWidgetName === legacyWidgetName
|
||||
) {
|
||||
matches.push({
|
||||
hostLocator: createNodeLocatorId(rootGraph.id, node.id),
|
||||
subgraphInputName: inputSlot.name
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
console.warn(
|
||||
'[appModeStore] dropping ambiguous legacy selectedInput tuple',
|
||||
{ storedId: legacySourceNodeId, widgetName: legacyWidgetName }
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
const { inputs, outputs } = pruneLinearData(data)
|
||||
selectedInputs.value = inputs
|
||||
@@ -154,10 +243,16 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
}
|
||||
|
||||
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
// ADR 0009: promoted widgets identify by `(hostNodeLocator,
|
||||
// subgraphInputName)` so that two host SubgraphNodes wrapping the same
|
||||
// Subgraph definition retain independent selections.
|
||||
const rootGraphId = app.rootGraph?.id
|
||||
const isPromoted = isPromotedWidgetView(widget)
|
||||
const storeId =
|
||||
isPromoted && rootGraphId
|
||||
? createNodeLocatorId(rootGraphId, node.id)
|
||||
: node.id
|
||||
const storeName = widget.name
|
||||
const index = selectedInputs.value.findIndex(
|
||||
([id, name]) => storeId == id && storeName === name
|
||||
)
|
||||
|
||||
@@ -61,6 +61,46 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
||||
app.nodePreviewImages = {}
|
||||
})
|
||||
|
||||
it('keeps execution-keyed outputs distinct from locator-keyed outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const firstOutput = createMockOutputs([{ filename: 'first.png' }])
|
||||
const secondOutput = createMockOutputs([{ filename: 'second.png' }])
|
||||
|
||||
store.setNodeOutputsByExecutionId('11:20:10', firstOutput)
|
||||
store.setNodeOutputsByExecutionId('12:20:10', secondOutput)
|
||||
|
||||
expect(store.getNodeOutputByExecutionId('11:20:10')).toEqual(firstOutput)
|
||||
expect(store.getNodeOutputByExecutionId('12:20:10')).toEqual(secondOutput)
|
||||
})
|
||||
|
||||
it('merges execution-keyed outputs when merge is true', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const initialOutput = createMockOutputs([{ filename: 'first.png' }])
|
||||
const nextOutput = createMockOutputs([{ filename: 'second.png' }])
|
||||
|
||||
store.setNodeOutputsByExecutionId('11:20:10', initialOutput)
|
||||
store.setNodeOutputsByExecutionId('11:20:10', nextOutput, { merge: true })
|
||||
|
||||
expect(store.getNodeOutputByExecutionId('11:20:10')?.images).toEqual([
|
||||
{ filename: 'first.png' },
|
||||
{ filename: 'second.png' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps execution-keyed previews distinct from locator-keyed previews', () => {
|
||||
const store = useNodeOutputStore()
|
||||
|
||||
store.setNodePreviewsByExecutionId('11:20:10', ['blob:first'])
|
||||
store.setNodePreviewsByExecutionId('12:20:10', ['blob:second'])
|
||||
|
||||
expect(store.getNodePreviewImagesByExecutionId('11:20:10')).toEqual([
|
||||
'blob:first'
|
||||
])
|
||||
expect(store.getNodePreviewImagesByExecutionId('12:20:10')).toEqual([
|
||||
'blob:second'
|
||||
])
|
||||
})
|
||||
|
||||
it('should update reactive nodeOutputs.value when merging outputs', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const executionId = '1'
|
||||
@@ -301,6 +341,14 @@ describe('nodeOutputStore getPreviewParam', () => {
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images only contains null entries', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
const outputs = createMockOutputs(fromAny([null]))
|
||||
expect(store.getPreviewParam(node, outputs)).toBe('')
|
||||
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return empty string if outputs.images contains SVG images', () => {
|
||||
const store = useNodeOutputStore()
|
||||
const node = createMockNode()
|
||||
|
||||
@@ -63,11 +63,15 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
|
||||
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
|
||||
const nodeOutputsByExecutionId = ref<
|
||||
Record<string, ExecutedWsMessage['output']>
|
||||
>({})
|
||||
|
||||
// Reactive state for node preview images - mirrors app.nodePreviewImages
|
||||
const nodePreviewImages = ref<Record<string, string[]>>(
|
||||
app.nodePreviewImages || {}
|
||||
)
|
||||
const nodePreviewImagesByExecutionId = ref<Record<string, string[]>>({})
|
||||
|
||||
function getNodeOutputs(
|
||||
node: LGraphNode
|
||||
@@ -93,9 +97,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
// If no images, return false
|
||||
if (!outputs?.images?.length) return false
|
||||
|
||||
const images = outputs.images.filter((image) => image != null)
|
||||
if (!images.length) return false
|
||||
|
||||
// If svg images, return false
|
||||
if (outputs.images.some((image) => image.filename?.endsWith('svg')))
|
||||
return false
|
||||
if (images.some((image) => image.filename?.endsWith('svg'))) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -124,10 +130,85 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
|
||||
return outputs.images.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
return outputs.images
|
||||
.filter((image) => image != null)
|
||||
.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
}
|
||||
|
||||
function getNodeOutputByExecutionId(
|
||||
executionId: string
|
||||
): ExecutedWsMessage['output'] | undefined {
|
||||
return nodeOutputsByExecutionId.value[executionId]
|
||||
}
|
||||
|
||||
function getNodePreviewImagesByExecutionId(
|
||||
executionId: string
|
||||
): string[] | undefined {
|
||||
return nodePreviewImagesByExecutionId.value[executionId]
|
||||
}
|
||||
|
||||
function getNodeImageUrlsByExecutionId(
|
||||
executionId: string,
|
||||
node: LGraphNode
|
||||
): string[] | undefined {
|
||||
const previews = getNodePreviewImagesByExecutionId(executionId)
|
||||
if (previews?.length) return previews
|
||||
|
||||
const outputs = getNodeOutputByExecutionId(executionId)
|
||||
if (!outputs?.images?.length) return
|
||||
|
||||
const rand = app.getRandParam()
|
||||
const previewParam = getPreviewParam(node, outputs)
|
||||
|
||||
return outputs.images
|
||||
.filter((image) => image != null)
|
||||
.map((image) => {
|
||||
const params = new URLSearchParams(image)
|
||||
return api.apiURL(`/view?${params}${previewParam}${rand}`)
|
||||
})
|
||||
}
|
||||
|
||||
function setExecutionPreviews(executionId: string, previewImages: string[]) {
|
||||
const existingPreviews = nodePreviewImagesByExecutionId.value[executionId]
|
||||
if (existingPreviews?.[Symbol.iterator]) {
|
||||
for (const url of existingPreviews) {
|
||||
releaseSharedObjectUrl(url)
|
||||
}
|
||||
}
|
||||
for (const url of previewImages) {
|
||||
retainSharedObjectUrl(url)
|
||||
}
|
||||
nodePreviewImagesByExecutionId.value[executionId] = previewImages
|
||||
}
|
||||
|
||||
function revokeExecutionPreviews(executionId: string) {
|
||||
const previews = nodePreviewImagesByExecutionId.value[executionId]
|
||||
if (!previews?.[Symbol.iterator]) return
|
||||
for (const url of previews) {
|
||||
releaseSharedObjectUrl(url)
|
||||
}
|
||||
delete nodePreviewImagesByExecutionId.value[executionId]
|
||||
}
|
||||
|
||||
function mergeOutputRecords(
|
||||
existingOutput: ExecutedWsMessage['output'],
|
||||
outputs: ExecutedWsMessage['output'] | ResultItem
|
||||
): ExecutedWsMessage['output'] {
|
||||
const merged = { ...existingOutput }
|
||||
for (const k in outputs) {
|
||||
const existingValue = merged[k]
|
||||
const newValue = (outputs as Record<NodeLocatorId, unknown>)[k]
|
||||
|
||||
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
|
||||
merged[k] = existingValue.concat(newValue)
|
||||
} else {
|
||||
merged[k] = newValue
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +323,14 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
outputs: ExecutedWsMessage['output'] | ResultItem,
|
||||
options: SetOutputOptions = {}
|
||||
) {
|
||||
if (outputs != null) {
|
||||
const existingOutput = nodeOutputsByExecutionId.value[executionId]
|
||||
nodeOutputsByExecutionId.value[executionId] =
|
||||
options.merge && existingOutput
|
||||
? mergeOutputRecords(existingOutput, outputs)
|
||||
: outputs
|
||||
}
|
||||
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
|
||||
@@ -259,6 +348,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
executionId: string,
|
||||
previewImages: string[]
|
||||
) {
|
||||
setExecutionPreviews(executionId, previewImages)
|
||||
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
setNodePreviewsByLocatorId(nodeLocatorId, previewImages)
|
||||
@@ -310,6 +401,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
* @param executionId - The execution ID
|
||||
*/
|
||||
function revokePreviewsByExecutionId(executionId: string) {
|
||||
revokeExecutionPreviews(executionId)
|
||||
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
scheduleRevoke(nodeLocatorId, () =>
|
||||
@@ -350,6 +443,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
}
|
||||
app.nodePreviewImages = {}
|
||||
nodePreviewImages.value = {}
|
||||
nodePreviewImagesByExecutionId.value = {}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,6 +535,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
function resetAllOutputsAndPreviews() {
|
||||
app.nodeOutputs = {}
|
||||
nodeOutputs.value = {}
|
||||
nodeOutputsByExecutionId.value = {}
|
||||
revokeAllPreviews()
|
||||
}
|
||||
|
||||
@@ -474,6 +569,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
// Getters
|
||||
getNodeOutputs,
|
||||
getNodeImageUrls,
|
||||
getNodeImageUrlsByExecutionId,
|
||||
getNodeOutputByExecutionId,
|
||||
getNodePreviewImagesByExecutionId,
|
||||
getNodePreviews,
|
||||
getPreviewParam,
|
||||
|
||||
@@ -499,7 +597,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
// State
|
||||
nodeOutputs,
|
||||
nodeOutputsByExecutionId,
|
||||
nodePreviewImages,
|
||||
nodePreviewImagesByExecutionId,
|
||||
latestPreview
|
||||
}
|
||||
})
|
||||
|
||||
318
src/stores/previewExposureStore.test.ts
Normal file
318
src/stores/previewExposureStore.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { usePreviewExposureStore } from './previewExposureStore'
|
||||
|
||||
describe(usePreviewExposureStore, () => {
|
||||
let store: ReturnType<typeof usePreviewExposureStore>
|
||||
const rootGraphA = 'root-graph-a' as UUID
|
||||
const rootGraphB = 'root-graph-b' as UUID
|
||||
const hostA = '7'
|
||||
const hostB = '8'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = usePreviewExposureStore()
|
||||
})
|
||||
|
||||
describe('getExposures', () => {
|
||||
it('returns empty readonly array for unknown host', () => {
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addExposure', () => {
|
||||
it('appends a new exposure and returns it with name = sourcePreviewName when no collision', () => {
|
||||
const entry = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
expect(entry).toEqual({
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([entry])
|
||||
})
|
||||
|
||||
it('disambiguates name collisions via nextUniqueName', () => {
|
||||
const first = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
const second = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
const third = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '44',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
expect(first.name).toBe('preview')
|
||||
expect(second.name).toBe('preview_1')
|
||||
expect(third.name).toBe('preview_2')
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'preview',
|
||||
'preview_1',
|
||||
'preview_2'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExposures', () => {
|
||||
it('replaces the array for the host', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
const next = [
|
||||
{
|
||||
name: 'replaced',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'other'
|
||||
}
|
||||
]
|
||||
store.setExposures(rootGraphA, hostA, next)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(next)
|
||||
})
|
||||
|
||||
it('clears the host bucket when given an empty array', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
store.setExposures(rootGraphA, hostA, [])
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeExposure', () => {
|
||||
beforeEach(() => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the matching entry by name', () => {
|
||||
store.removeExposure(rootGraphA, hostA, 'preview_1')
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'preview'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when no entry matches', () => {
|
||||
const before = store.getExposures(rootGraphA, hostA)
|
||||
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveExposure', () => {
|
||||
beforeEach(() => {
|
||||
store.setExposures(rootGraphA, hostA, [
|
||||
{ name: 'a', sourceNodeId: '1', sourcePreviewName: 'a' },
|
||||
{ name: 'b', sourceNodeId: '2', sourcePreviewName: 'b' },
|
||||
{ name: 'c', sourceNodeId: '3', sourcePreviewName: 'c' }
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders entries from -> to', () => {
|
||||
store.moveExposure(rootGraphA, hostA, 0, 2)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for equal indices', () => {
|
||||
store.moveExposure(rootGraphA, hostA, 1, 1)
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for out-of-bounds indices', () => {
|
||||
store.moveExposure(rootGraphA, hostA, -1, 2)
|
||||
store.moveExposure(rootGraphA, hostA, 0, 5)
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGraph', () => {
|
||||
it('removes all hosts under the rootGraphId without affecting others', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '2',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
const hostInB = '7'
|
||||
store.addExposure(rootGraphB, hostInB, {
|
||||
sourceNodeId: '3',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
|
||||
store.clearGraph(rootGraphA)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
expect(store.getExposures(rootGraphA, hostB)).toEqual([])
|
||||
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation between (rootGraphId, hostNodeLocator) pairs', () => {
|
||||
it('keeps separate buckets per host and per root graph', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '2',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
const hostInB = '7'
|
||||
store.addExposure(rootGraphB, hostInB, {
|
||||
sourceNodeId: '3',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphA, hostA)[0].sourceNodeId).toBe('1')
|
||||
expect(store.getExposures(rootGraphA, hostB)[0].sourceNodeId).toBe('2')
|
||||
expect(store.getExposures(rootGraphB, hostInB)[0].sourceNodeId).toBe('3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveChain', () => {
|
||||
it('returns a single-step chain for an existing exposure when no resolver is provided', () => {
|
||||
const entry = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
const result = store.resolveChain(rootGraphA, hostA, entry.name)
|
||||
|
||||
expect(result?.steps).toHaveLength(1)
|
||||
expect(result?.steps[0]).toMatchObject({
|
||||
rootGraphId: rootGraphA,
|
||||
hostNodeLocator: hostA,
|
||||
exposure: {
|
||||
name: 'preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
}
|
||||
})
|
||||
expect(result?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when the named exposure is missing', () => {
|
||||
expect(store.resolveChain(rootGraphA, hostA, 'absent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('walks one nested host when a resolver is provided', () => {
|
||||
const innerHost = '99'
|
||||
store.addExposure(rootGraphA, innerHost, {
|
||||
sourceNodeId: 'inner-leaf',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
})
|
||||
const outer = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
})
|
||||
|
||||
const resolved = store.resolveChain(
|
||||
rootGraphA,
|
||||
hostA,
|
||||
outer.name,
|
||||
(rootGraphId, hostLocator, sourceNodeId) => {
|
||||
if (hostLocator === hostA && sourceNodeId === '99') {
|
||||
return { rootGraphId, hostNodeLocator: innerHost }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(resolved?.steps).toHaveLength(2)
|
||||
expect(resolved?.steps[0].hostNodeLocator).toBe(hostA)
|
||||
expect(resolved?.steps[1].hostNodeLocator).toBe(innerHost)
|
||||
expect(resolved?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: 'inner-leaf',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('walks two nested hosts (three-step chain)', () => {
|
||||
const inner = '50'
|
||||
const innermost = '60'
|
||||
store.addExposure(rootGraphA, innermost, {
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
store.addExposure(rootGraphA, inner, {
|
||||
sourceNodeId: '60',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
const outer = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '50',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
const resolved = store.resolveChain(
|
||||
rootGraphA,
|
||||
hostA,
|
||||
outer.name,
|
||||
(rootGraphId, hostLocator, sourceNodeId) => {
|
||||
if (hostLocator === hostA && sourceNodeId === '50')
|
||||
return { rootGraphId, hostNodeLocator: inner }
|
||||
if (hostLocator === inner && sourceNodeId === '60')
|
||||
return { rootGraphId, hostNodeLocator: innermost }
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(resolved?.steps).toHaveLength(3)
|
||||
expect(resolved?.steps.map((s) => s.hostNodeLocator)).toEqual([
|
||||
hostA,
|
||||
inner,
|
||||
innermost
|
||||
])
|
||||
expect(resolved?.leaf).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
sourceNodeId: 'leaf',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
153
src/stores/previewExposureStore.ts
Normal file
153
src/stores/previewExposureStore.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PreviewExposureChainContext } from '@/core/graph/subgraph/preview/previewExposureChain'
|
||||
import { resolvePreviewExposureChain } from '@/core/graph/subgraph/preview/previewExposureChain'
|
||||
import type { ResolvedPreviewChain } from '@/core/graph/subgraph/preview/previewExposureTypes'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])
|
||||
|
||||
/**
|
||||
* Optional resolver passed by callers that want {@link resolveChain} to walk
|
||||
* nested subgraph host boundaries.
|
||||
*/
|
||||
export type ResolveNestedHostFn = NonNullable<
|
||||
PreviewExposureChainContext['resolveNestedHost']
|
||||
>
|
||||
|
||||
export const usePreviewExposureStore = defineStore('previewExposure', () => {
|
||||
// Host ids are execution paths like `11` or `11:20`, not NodeLocatorIds.
|
||||
const exposures = ref(new Map<UUID, Map<string, PreviewExposure[]>>())
|
||||
|
||||
function _getHostsForGraph(
|
||||
rootGraphId: UUID
|
||||
): Map<string, PreviewExposure[]> {
|
||||
const hosts = exposures.value.get(rootGraphId)
|
||||
if (hosts) return hosts
|
||||
|
||||
const nextHosts = new Map<string, PreviewExposure[]>()
|
||||
exposures.value.set(rootGraphId, nextHosts)
|
||||
return nextHosts
|
||||
}
|
||||
|
||||
function _getExposuresRef(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): PreviewExposure[] | undefined {
|
||||
return exposures.value.get(rootGraphId)?.get(hostNodeLocator)
|
||||
}
|
||||
|
||||
function getExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): readonly PreviewExposure[] {
|
||||
return _getExposuresRef(rootGraphId, hostNodeLocator) ?? EMPTY_EXPOSURES
|
||||
}
|
||||
|
||||
function setExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
next: readonly PreviewExposure[]
|
||||
): void {
|
||||
const hosts = _getHostsForGraph(rootGraphId)
|
||||
if (next.length === 0) {
|
||||
hosts.delete(hostNodeLocator)
|
||||
if (hosts.size === 0) exposures.value.delete(rootGraphId)
|
||||
return
|
||||
}
|
||||
hosts.set(hostNodeLocator, [...next])
|
||||
}
|
||||
|
||||
function addExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
source: { sourceNodeId: string; sourcePreviewName: string }
|
||||
): PreviewExposure {
|
||||
const hosts = _getHostsForGraph(rootGraphId)
|
||||
const current = hosts.get(hostNodeLocator) ?? []
|
||||
const existingNames = current.map((e) => e.name)
|
||||
const name = nextUniqueName(source.sourcePreviewName, existingNames)
|
||||
const entry: PreviewExposure = {
|
||||
name,
|
||||
sourceNodeId: source.sourceNodeId,
|
||||
sourcePreviewName: source.sourcePreviewName
|
||||
}
|
||||
hosts.set(hostNodeLocator, [...current, entry])
|
||||
return entry
|
||||
}
|
||||
|
||||
function removeExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string
|
||||
): void {
|
||||
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
|
||||
if (!current?.length) return
|
||||
const next = current.filter((e) => e.name !== name)
|
||||
if (next.length === current.length) return
|
||||
setExposures(rootGraphId, hostNodeLocator, next)
|
||||
}
|
||||
|
||||
function moveExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): void {
|
||||
const hosts = exposures.value.get(rootGraphId)
|
||||
const current = hosts?.get(hostNodeLocator)
|
||||
if (!hosts || !current?.length) return
|
||||
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
fromIndex >= current.length ||
|
||||
toIndex < 0 ||
|
||||
toIndex >= current.length ||
|
||||
fromIndex === toIndex
|
||||
)
|
||||
return
|
||||
|
||||
const next = [...current]
|
||||
const [entry] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, entry)
|
||||
hosts.set(hostNodeLocator, next)
|
||||
}
|
||||
|
||||
function clearGraph(rootGraphId: UUID): void {
|
||||
exposures.value.delete(rootGraphId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chain of exposures from a host down to the originating source
|
||||
* preview, optionally walking through nested subgraph hosts.
|
||||
*
|
||||
* @param resolveNestedHost If provided, the walker recurses through nested
|
||||
* SubgraphNode boundaries by calling this resolver. Without it, the chain is
|
||||
* a single-step walk on the starting host.
|
||||
*/
|
||||
function resolveChain(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string,
|
||||
resolveNestedHost?: ResolveNestedHostFn
|
||||
): ResolvedPreviewChain | undefined {
|
||||
const ctx: PreviewExposureChainContext = {
|
||||
getExposures,
|
||||
resolveNestedHost: resolveNestedHost ?? (() => undefined)
|
||||
}
|
||||
return resolvePreviewExposureChain(rootGraphId, hostNodeLocator, name, ctx)
|
||||
}
|
||||
|
||||
return {
|
||||
getExposures,
|
||||
setExposures,
|
||||
addExposure,
|
||||
removeExposure,
|
||||
moveExposure,
|
||||
clearGraph,
|
||||
resolveChain
|
||||
}
|
||||
})
|
||||
@@ -1,892 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { usePromotionStore } from './promotionStore'
|
||||
|
||||
describe(usePromotionStore, () => {
|
||||
let store: ReturnType<typeof usePromotionStore>
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
const nodeId = 1 as NodeId
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = usePromotionStore()
|
||||
})
|
||||
|
||||
describe('getPromotions', () => {
|
||||
it('returns empty array for unknown node', () => {
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a stable empty ref for unknown node', () => {
|
||||
const first = store.getPromotionsRef(graphA, nodeId)
|
||||
const second = store.getPromotionsRef(graphA, nodeId)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns entries after setPromotions', () => {
|
||||
const entries = [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
})
|
||||
|
||||
it('returns a defensive copy', () => {
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
|
||||
const result = store.getPromotions(graphA, nodeId)
|
||||
result.push({ sourceNodeId: '11', sourceWidgetName: 'steps' })
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPromoted', () => {
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a promoted entry', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a different widget on the same node', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPromotedByAny', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('returns false when nothing is promoted', () => {
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when promoted by one parent', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when promoted by multiple parents', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false after demoting from all parents', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when still promoted by one parent after partial demote', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for different widget on same node', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions', () => {
|
||||
it('replaces existing entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.setPromotions(graphA, nodeId, [
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears entries when set to empty array', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.setPromotions(graphA, nodeId, [])
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves order', () => {
|
||||
const entries = [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promote', () => {
|
||||
it('adds a new entry', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not duplicate existing entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('appends to existing entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('demote', () => {
|
||||
it('removes an existing entry', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op for non-existent entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '99',
|
||||
sourceWidgetName: 'nonexistent'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves other entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('movePromotion', () => {
|
||||
it('moves an entry from one index to another', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '12',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 0, 2)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' },
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for out-of-bounds indices', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 0, 5)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when fromIndex equals toIndex', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.movePromotion(graphA, nodeId, 1, 1)
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ref-counted isPromotedByAny', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('tracks across setPromotions calls', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeB, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Remove from A — still promoted by B
|
||||
store.setPromotions(graphA, nodeA, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Remove from B — now gone
|
||||
store.setPromotions(graphA, nodeB, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('handles replacement via setPromotions correctly', () => {
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
// Replace with different entries
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
|
||||
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '12',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays consistent through movePromotion', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
store.movePromotion(graphA, nodeA, 0, 1)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-node isolation', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('keeps promotions separate per subgraph node', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'cfg'
|
||||
})
|
||||
|
||||
expect(store.getPromotions(graphA, nodeA)).toEqual([
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphA, nodeB)).toEqual([
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'cfg' }
|
||||
])
|
||||
})
|
||||
|
||||
it('demoting from one node does not affect another', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeB, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGraph resets ref counts', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
|
||||
it('resets isPromotedByAny after clearGraph', () => {
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphA, nodeB, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPromotions idempotency', () => {
|
||||
it('does not double ref counts when called twice with same entries', () => {
|
||||
const entries = [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
]
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
store.setPromotions(graphA, nodeId, entries)
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeId, [])
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promote/demote interleaved with setPromotions', () => {
|
||||
it('maintains consistent ref counts through mixed operations', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
|
||||
store.promote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
|
||||
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.demote(graphA, nodeA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '11',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates promotions by graph id', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphB, nodeId, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
|
||||
])
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
store.promote(graphB, nodeId, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getPromotions(graphA, nodeId)).toEqual([])
|
||||
expect(store.getPromotions(graphB, nodeId)).toEqual([
|
||||
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphB, {
|
||||
sourceNodeId: '20',
|
||||
sourceWidgetName: 'steps'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sourceNodeId disambiguation', () => {
|
||||
it('promote with disambiguatingSourceNodeId is found by matching isPromoted', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromoted with different disambiguatingSourceNodeId returns false', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '88'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('isPromoted with undefined disambiguatingSourceNodeId does not match entry with disambiguatingSourceNodeId', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '99'
|
||||
})
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('two entries with same sourceNodeId/sourceWidgetName but different disambiguatingSourceNodeId coexist', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('demote with disambiguatingSourceNodeId removes only matching entry', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
store.demote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromoted(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
|
||||
store.promote(graphA, nodeId, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '2'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
|
||||
const nodeA = 1 as NodeId
|
||||
const nodeB = 2 as NodeId
|
||||
store.setPromotions(graphA, nodeA, [
|
||||
{
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
}
|
||||
])
|
||||
store.setPromotions(graphA, nodeB, [
|
||||
{
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
}
|
||||
])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeA, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
store.setPromotions(graphA, nodeB, [])
|
||||
expect(
|
||||
store.isPromotedByAny(graphA, {
|
||||
sourceNodeId: '3',
|
||||
sourceWidgetName: 'text',
|
||||
disambiguatingSourceNodeId: '1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
|
||||
|
||||
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
|
||||
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
|
||||
return source.disambiguatingSourceNodeId
|
||||
? `${base}:${source.disambiguatingSourceNodeId}`
|
||||
: base
|
||||
}
|
||||
|
||||
export const usePromotionStore = defineStore('promotion', () => {
|
||||
const graphPromotions = ref(
|
||||
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
|
||||
)
|
||||
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
|
||||
|
||||
function _getPromotionsForGraph(
|
||||
graphId: UUID
|
||||
): Map<NodeId, PromotedWidgetSource[]> {
|
||||
const promotions = graphPromotions.value.get(graphId)
|
||||
if (promotions) return promotions
|
||||
|
||||
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
|
||||
graphPromotions.value.set(graphId, nextPromotions)
|
||||
return nextPromotions
|
||||
}
|
||||
|
||||
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
|
||||
const refCounts = graphRefCounts.value.get(graphId)
|
||||
if (refCounts) return refCounts
|
||||
|
||||
const nextRefCounts = new Map<string, number>()
|
||||
graphRefCounts.value.set(graphId, nextRefCounts)
|
||||
return nextRefCounts
|
||||
}
|
||||
|
||||
function _incrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function _decrementKeys(
|
||||
graphId: UUID,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
for (const e of entries) {
|
||||
const key = makePromotionEntryKey(e)
|
||||
const count = (refCounts.get(key) ?? 1) - 1
|
||||
if (count <= 0) {
|
||||
refCounts.delete(key)
|
||||
} else {
|
||||
refCounts.set(key, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPromotionsRef(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotedWidgetSource[] {
|
||||
return (
|
||||
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
|
||||
)
|
||||
}
|
||||
|
||||
function getPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId
|
||||
): PromotedWidgetSource[] {
|
||||
return [...getPromotionsRef(graphId, subgraphNodeId)]
|
||||
}
|
||||
|
||||
function isPromoted(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
return getPromotionsRef(graphId, subgraphNodeId).some(
|
||||
(e) =>
|
||||
e.sourceNodeId === source.sourceNodeId &&
|
||||
e.sourceWidgetName === source.sourceWidgetName &&
|
||||
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
function isPromotedByAny(
|
||||
graphId: UUID,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
const refCounts = _getRefCountsForGraph(graphId)
|
||||
return (refCounts.get(makePromotionEntryKey(source)) ?? 0) > 0
|
||||
}
|
||||
|
||||
function setPromotions(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
entries: PromotedWidgetSource[]
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const oldEntries = promotions.get(subgraphNodeId) ?? []
|
||||
|
||||
_decrementKeys(graphId, oldEntries)
|
||||
_incrementKeys(graphId, entries)
|
||||
|
||||
if (entries.length === 0) {
|
||||
promotions.delete(subgraphNodeId)
|
||||
} else {
|
||||
promotions.set(subgraphNodeId, [...entries])
|
||||
}
|
||||
}
|
||||
|
||||
function promote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
source: PromotedWidgetSource
|
||||
): void {
|
||||
if (isPromoted(graphId, subgraphNodeId, source)) return
|
||||
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
const entry: PromotedWidgetSource = {
|
||||
sourceNodeId: source.sourceNodeId,
|
||||
sourceWidgetName: source.sourceWidgetName
|
||||
}
|
||||
if (source.disambiguatingSourceNodeId)
|
||||
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
|
||||
setPromotions(graphId, subgraphNodeId, [...entries, entry])
|
||||
}
|
||||
|
||||
function demote(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
source: PromotedWidgetSource
|
||||
): void {
|
||||
const entries = getPromotionsRef(graphId, subgraphNodeId)
|
||||
setPromotions(
|
||||
graphId,
|
||||
subgraphNodeId,
|
||||
entries.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.sourceNodeId === source.sourceNodeId &&
|
||||
e.sourceWidgetName === source.sourceWidgetName &&
|
||||
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function movePromotion(
|
||||
graphId: UUID,
|
||||
subgraphNodeId: NodeId,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): void {
|
||||
const promotions = _getPromotionsForGraph(graphId)
|
||||
const currentEntries = promotions.get(subgraphNodeId)
|
||||
if (!currentEntries?.length) return
|
||||
|
||||
const entries = [...currentEntries]
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
fromIndex >= entries.length ||
|
||||
toIndex < 0 ||
|
||||
toIndex >= entries.length ||
|
||||
fromIndex === toIndex
|
||||
)
|
||||
return
|
||||
|
||||
const [entry] = entries.splice(fromIndex, 1)
|
||||
entries.splice(toIndex, 0, entry)
|
||||
|
||||
promotions.set(subgraphNodeId, entries)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphPromotions.value.delete(graphId)
|
||||
graphRefCounts.value.delete(graphId)
|
||||
}
|
||||
|
||||
return {
|
||||
getPromotionsRef,
|
||||
getPromotions,
|
||||
isPromoted,
|
||||
isPromotedByAny,
|
||||
setPromotions,
|
||||
promote,
|
||||
demote,
|
||||
movePromotion,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
@@ -26,6 +26,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { t } from '@/i18n'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
|
||||
type VideoNode = LGraphNode & {
|
||||
@@ -333,6 +334,17 @@ export function resolveNodeWidget(
|
||||
widgetName?: string,
|
||||
graph: LGraph = app.rootGraph
|
||||
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
|
||||
if (widgetName && typeof nodeId === 'string') {
|
||||
const locator = parseNodeLocatorId(nodeId)
|
||||
if (locator?.subgraphUuid) {
|
||||
const host = graph.getNodeById(locator.localNodeId)
|
||||
if (host?.isSubgraphNode()) {
|
||||
const widget = host.widgets?.find((w) => w.name === widgetName)
|
||||
return widget ? [host, widget] : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const node = graph.getNodeById(nodeId)
|
||||
if (!widgetName) return node ? [node] : []
|
||||
if (node) {
|
||||
|
||||
@@ -128,21 +128,6 @@ describe('renameWidget', () => {
|
||||
expect(promotedWidget.label).toBe('Renamed')
|
||||
})
|
||||
|
||||
it('updates _subgraphSlot.label when input has a subgraph slot', () => {
|
||||
const widget = makeWidget({ name: 'seed' })
|
||||
const subgraphSlot = { label: undefined as string | undefined }
|
||||
const input = fromAny<INodeInputSlot, unknown>({
|
||||
name: 'seed',
|
||||
widget: { name: 'seed' },
|
||||
_subgraphSlot: subgraphSlot
|
||||
})
|
||||
const node = makeNode({ inputs: [input] })
|
||||
|
||||
renameWidget(widget, node, 'New Label')
|
||||
|
||||
expect(subgraphSlot.label).toBe('New Label')
|
||||
})
|
||||
|
||||
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
|
||||
const promotedWidget = makeWidget({
|
||||
name: 'seed',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -78,11 +77,6 @@ export function renameWidget(
|
||||
widget.label = newLabel || undefined
|
||||
if (input) {
|
||||
input.label = newLabel || undefined
|
||||
|
||||
const subgraphSlot = (input as Partial<ISubgraphInput>)._subgraphSlot
|
||||
if (subgraphSlot) {
|
||||
subgraphSlot.label = newLabel || undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Fires for all node types; listeners guard against non-subgraph nodes.
|
||||
|
||||
Reference in New Issue
Block a user