mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-27 08:25:50 +00:00
feat(subgraph): Subgraph Link Only Promotion (ADR 0009) + migration/store hygiene (#12197)
## Summary Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for surfacing inner subgraph widgets on the parent SubgraphNode by *promoting through links* rather than by duplicating widget state on the host. Ships with the hygiene/refactor pass on the migration, store, and event layers that the new model depends on. ## What changes ### Subgraph Link Only Promotion (ADR 0009) Promoted widgets are defined by the link from a SubgraphNode input to the interior node, not by a duplicated widget instance on the host. Consequences: - A SubgraphNode renders inner widgets purely as a **projection** of the interior widgets and links — no host-side state to drift. - **Per-host independence**: multiple instances of the same SubgraphNode render and edit their own values without cross-talk. - **Reversible promote/demote**: structural link operation, so demote preserves host slots and external connections (#12278). ### Supporting refactors - **Migration** — Planner/classifier/repair/quarantine helpers collapsed into a single `proxyWidgetMigration` entry point with black-box round-trip coverage. Honors the source-node-id disambiguator on `proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to the right interior widget. - **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted widget state is keyed by entityId across the store, DOM, and migration paths. - **SubgraphNode** — 3-key promoted-view cache replaced with a single version counter + explicit `invalidatePromotedViews()` at mutation sites; `id === -1` sentinel removed. - **Events** — `LGraph.trigger()` now dispatches node trigger payloads through `this.events`, replacing a leaky `onTrigger` monkey-patch. `SubgraphEditor` reactivity is driven from subgraph events instead of imperative refresh. - **Stores** — `appModeStore` migration helpers collapsed into `upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from the locator index; `previewExposureStore` cleanup and cycle-detection double-warn fix. - **Misc** — `Outcome` types consolidated; mutable accumulators replaced with `flatMap`; new ESLint rule forbids litegraph imports under `src/world/`. ### Tests - Browser tests for promoted widgets retagged `@vue-nodes` and rewritten to assert against the rendered Vue node DOM (via `getNodeLocator` / `getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate` graph introspection. - Per-host widget independence asserted via DOM. - Migration coverage moved to black-box round-trip tests. - Added coverage for duplicate-named promoted widget identity (ADR 0009) and the per-parent demote branch in `WidgetActions`. ## Review focus - ADR 0009 conformance of the link-only promotion model. - Disambiguator resolution path in `proxyWidgetMigration`. - Single-version-counter promoted-view cache and its `invalidatePromotedViews()` call sites. - `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue` migration off `onTrigger` (FE-667 tracks the remaining `useGraphNodeManager` conversion). ## Breaking changes None for users. Internal subgraph promotion APIs changed — see ADR 0009. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com> Co-authored-by: AustinMroz <austin@comfy.org>
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -307,6 +307,20 @@ When referencing Comfy-Org repos:
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER add multi-line block comments to justify trivial code changes
|
||||
- A one-line fix does not need a three-line comment explaining why
|
||||
- A guard clause that mirrors another file does not need a comment naming that file
|
||||
- A test setup line does not need a comment paraphrasing what the next line does
|
||||
- If the diff is small and obvious, the comment is noise — write the code and move on
|
||||
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
|
||||
- **Penance protocol when you catch yourself adding one of these comments:**
|
||||
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
|
||||
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
|
||||
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
|
||||
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
|
||||
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
|
||||
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
|
||||
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
|
||||
- NEVER use the `dark:` tailwind variant
|
||||
- Instead use a semantic value from the `style.css` theme
|
||||
- e.g. `bg-node-component-surface`
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["first-host", 11]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [900, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["1", "value"],
|
||||
["4", "value"]
|
||||
]
|
||||
},
|
||||
"widgets_values": ["second-host", 22]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
|
||||
"revision": 0,
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"pos": [602, 409],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"proxyWidgets": [["9999", "missing_widget"]]
|
||||
},
|
||||
"widgets_values": ["quarantined-host-value"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 4,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [349, 383, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [867, 383, 128, 48]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"linkIds": [1],
|
||||
"pos": [453, 407]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveString",
|
||||
"pos": [537, 368],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "STRING",
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveString"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [534.9899497487436, 515.4924623115581],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "value",
|
||||
"name": "value",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "INT",
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": [258.4381232333541, 549.1608040200999],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "value"
|
||||
},
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "INT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.44.17"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -17,8 +17,9 @@ export class SubgraphEditor {
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
async ensureOpen(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
if (await this.root.isVisible()) return
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
@@ -69,7 +70,7 @@ export class SubgraphEditor {
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
await this.ensureOpen(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
|
||||
@@ -6,8 +6,9 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
|
||||
/**
|
||||
* Helper for interacting with widgets rendered in app mode (linear view).
|
||||
*
|
||||
* Widgets are located by their key (format: "nodeId:widgetName") via the
|
||||
* `data-widget-key` attribute on each widget item.
|
||||
* Widgets are located by `nodeId:widgetName` suffix against the
|
||||
* `data-widget-key` attribute, which carries the canonical
|
||||
* `graphId:nodeId:widgetName` WidgetEntityId.
|
||||
*/
|
||||
export class AppModeWidgetHelper {
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
@@ -20,9 +21,9 @@ export class AppModeWidgetHelper {
|
||||
return this.comfyPage.appMode.linearWidgets
|
||||
}
|
||||
|
||||
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
|
||||
/** Get a widget item container by its `nodeId:widgetName` suffix. */
|
||||
getWidgetItem(key: string): Locator {
|
||||
return this.container.locator(`[data-widget-key="${key}"]`)
|
||||
return this.container.locator(`[data-widget-key$=":${key}"]`)
|
||||
}
|
||||
|
||||
/** Get a FormDropdown widget by its key (e.g. "10:image"). */
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
@@ -423,39 +425,9 @@ export class SubgraphHelper {
|
||||
}
|
||||
|
||||
async getHostPromotedTupleSnapshot(): Promise<
|
||||
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
|
||||
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => {
|
||||
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
|
||||
? node.properties.proxyWidgets
|
||||
: []
|
||||
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]
|
||||
)
|
||||
|
||||
return {
|
||||
hostNodeId: String(node.id),
|
||||
promotedWidgets
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
})
|
||||
return getAllHostPromotedWidgets(this.comfyPage)
|
||||
}
|
||||
|
||||
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
|
||||
|
||||
@@ -511,8 +511,7 @@ export class NodeReference {
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
await this.comfyPage.contextMenu.clickMenuItem(optionText)
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
export type PromotedWidgetEntry = [string, string]
|
||||
|
||||
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
|
||||
function widgetSourceToEntry(
|
||||
source: PromotedWidgetSource
|
||||
): PromotedWidgetEntry {
|
||||
return [source.sourceNodeId, source.sourceWidgetName]
|
||||
}
|
||||
|
||||
function previewExposureToEntry(
|
||||
exposure: PreviewExposure
|
||||
): PromotedWidgetEntry {
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
return (
|
||||
Array.isArray(entry) &&
|
||||
entry.length === 2 &&
|
||||
typeof entry[0] === 'string' &&
|
||||
typeof entry[1] === 'string'
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'sourceNodeId' in value &&
|
||||
'sourceWidgetName' in value &&
|
||||
typeof value.sourceNodeId === 'string' &&
|
||||
typeof value.sourceWidgetName === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter(isPromotedWidgetEntry)
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
}
|
||||
|
||||
export async function getPromotedWidgets(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: string
|
||||
): Promise<PromotedWidgetEntry[]> {
|
||||
const raw = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgets = node?.widgets ?? []
|
||||
|
||||
// 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) => {
|
||||
if (
|
||||
widget &&
|
||||
typeof widget === 'object' &&
|
||||
'sourceNodeId' in widget &&
|
||||
typeof widget.sourceNodeId === 'string' &&
|
||||
'sourceWidgetName' in widget &&
|
||||
typeof widget.sourceWidgetName === 'string'
|
||||
) {
|
||||
return [[widget.sourceNodeId, widget.sourceWidgetName]]
|
||||
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
|
||||
(id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
|
||||
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
|
||||
return []
|
||||
return [
|
||||
{
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
]
|
||||
})
|
||||
const serializedNode = node?.serialize()
|
||||
return {
|
||||
widgetSources,
|
||||
previewExposures: serializedNode?.properties?.previewExposures
|
||||
}
|
||||
return []
|
||||
})
|
||||
}, nodeId)
|
||||
},
|
||||
nodeId
|
||||
)
|
||||
|
||||
return normalizePromotedWidgets(raw)
|
||||
const exposures = isNodeProperty(previewExposures)
|
||||
? parsePreviewExposures(previewExposures)
|
||||
: []
|
||||
return [
|
||||
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
|
||||
...exposures.map(previewExposureToEntry)
|
||||
]
|
||||
}
|
||||
|
||||
export async function getPromotedWidgetNames(
|
||||
@@ -78,12 +107,29 @@ 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
|
||||
}
|
||||
|
||||
export async function getAllHostPromotedWidgets(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
|
||||
const hostNodeIds = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph._nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
|
||||
)
|
||||
.map((node) => String(node.id))
|
||||
})
|
||||
|
||||
const entries = await Promise.all(
|
||||
hostNodeIds.map(async (hostNodeId) => ({
|
||||
hostNodeId,
|
||||
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
|
||||
}))
|
||||
)
|
||||
|
||||
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-off.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await expect(comfyPage.page).toHaveScreenshot(
|
||||
'canvas-info-hud-on.png',
|
||||
{ clip: hudClip, maxDiffPixels: 50 }
|
||||
{ clip: hudClip, maxDiffPixels: 100 }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,36 +3,40 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
test(
|
||||
'Price badge displays on subgraphs',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
await comfyPage.searchBoxV2.addNode(apiNodeName)
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 44 KiB |
@@ -40,49 +40,19 @@ test.describe(
|
||||
)
|
||||
const [nodeId1, nodeId2] = nodeIds
|
||||
|
||||
// Enter first subgraph, set text widget value
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea1.fill('subgraph1_value')
|
||||
await expect(textarea1).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const promotedTextarea = (nodeId: string) =>
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
|
||||
// Enter second subgraph, set text widget value
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2 = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await textarea2.fill('subgraph2_value')
|
||||
await expect(textarea2).toHaveValue('subgraph2_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await promotedTextarea(nodeId1).fill('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
|
||||
// Re-enter first subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId1)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea1Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea1Again).toHaveValue('subgraph1_value')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await promotedTextarea(nodeId2).fill('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
|
||||
// Re-enter second subgraph, assert value preserved
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.enterSubgraph(nodeId2)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const textarea2Again = comfyPage.vueNodes
|
||||
.getNodeByTitle(clipNodeTitle)
|
||||
.first()
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(textarea2Again).toHaveValue('subgraph2_value')
|
||||
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
|
||||
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
|
||||
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
|
||||
test('Deleting the promoted source removes the exterior DOM widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Cleanup Behavior After Promoted Source Removal',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Deleting the promoted source removes the exterior promoted widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await clipNode.delete()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
|
||||
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({
|
||||
|
||||
@@ -34,49 +34,43 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
test.describe(
|
||||
'Nested subgraph duplicate widget names',
|
||||
{ tag: ['@widget'] },
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const OUTER_NODE_ID = '4'
|
||||
const INNER_SUBGRAPH_NODE_ID = '3'
|
||||
|
||||
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await comfyExpect(async () => {
|
||||
const widgetValues = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const outerNode = graph.getNodeById('4')
|
||||
if (
|
||||
!outerNode ||
|
||||
typeof outerNode.isSubgraphNode !== 'function' ||
|
||||
!outerNode.isSubgraphNode()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
|
||||
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
|
||||
if (!innerSubgraphNode) return []
|
||||
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
await comfyExpect(outerWidgets).toHaveCount(1)
|
||||
|
||||
return (innerSubgraphNode.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
value: w.value
|
||||
}))
|
||||
})
|
||||
const exposedTextWidget = outerNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
||||
|
||||
const textWidgets = widgetValues.filter((w) =>
|
||||
w.name.startsWith('text')
|
||||
)
|
||||
comfyExpect(textWidgets).toHaveLength(2)
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
||||
|
||||
const values = textWidgets.map((w) => w.value)
|
||||
comfyExpect(values).toContain('11111111111')
|
||||
comfyExpect(values).toContain('22222222222')
|
||||
}).toPass({ timeout: 5_000 })
|
||||
const innerNode = comfyPage.vueNodes.getNodeLocator(
|
||||
INNER_SUBGRAPH_NODE_ID
|
||||
)
|
||||
await comfyExpect(innerNode).toBeVisible()
|
||||
|
||||
const innerTextboxes = innerNode.getByRole('textbox')
|
||||
await comfyExpect(innerTextboxes).toHaveCount(2)
|
||||
const innerValues = await innerTextboxes.evaluateAll<
|
||||
string[],
|
||||
HTMLInputElement
|
||||
>((boxes) => boxes.map((b) => b.value))
|
||||
comfyExpect(innerValues).toContain('11111111111')
|
||||
comfyExpect(innerValues).toContain('22222222222')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -96,7 +90,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeLocator).toBeVisible()
|
||||
@@ -129,7 +122,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
||||
await comfyExpect(nodeAfter).toBeVisible()
|
||||
@@ -176,7 +168,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -210,7 +201,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -231,7 +221,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
await comfyExpect(outerNode).toBeVisible()
|
||||
@@ -250,7 +239,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -268,7 +256,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
||||
@@ -279,7 +266,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
||||
const initialCount = await widgets.count()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
||||
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
||||
|
||||
@@ -61,15 +61,12 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Select the positive CLIPTextEncode node (id 6)
|
||||
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
||||
await clipNode.click('title')
|
||||
const subgraphNode = await clipNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeId = String(subgraphNode.id)
|
||||
// CLIPTextEncode is in the recommendedNodes list, so its text widget
|
||||
// should be promoted
|
||||
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
|
||||
})
|
||||
|
||||
@@ -78,7 +75,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -86,7 +82,6 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
String(subgraphNode.id),
|
||||
@@ -95,88 +90,73 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
|
||||
test('Promoted text widget is visible on SubgraphNode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Promoted Widget Visibility in Vue Mode',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
|
||||
test.describe('Promoted Widget Visibility in Vue Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
const enterButton = subgraphVueNode.getByTestId(
|
||||
'subgraph-enter-button'
|
||||
)
|
||||
await expect(enterButton).toBeVisible()
|
||||
|
||||
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
|
||||
await expect(enterButton).toBeVisible()
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
||||
await expect(nodeBody).toBeVisible()
|
||||
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
|
||||
test.fail(
|
||||
'Promoted and interior widgets stay in sync across navigation',
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
||||
await expect(widgets.first()).toBeVisible()
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
})
|
||||
})
|
||||
const promotedTextarea = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await promotedTextarea.fill(testContent)
|
||||
|
||||
test.describe('Promoted Widget Reactivity', () => {
|
||||
test('Promoted and interior widgets stay in sync across navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
const testContent = 'promoted-value-sync-test'
|
||||
const interiorTextarea = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
.first()
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const interiorTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(interiorTextarea).toHaveValue(testContent)
|
||||
|
||||
const updatedInteriorContent = 'interior-value-sync-test'
|
||||
await interiorTextarea.fill(updatedInteriorContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const promotedTextarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveValue(updatedInteriorContent)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Manual Promote/Demote via Context Menu', () => {
|
||||
@@ -195,7 +175,6 @@ test.describe(
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
.filter({ hasText: /Promote Widget/ })
|
||||
@@ -204,10 +183,8 @@ test.describe(
|
||||
await promoteEntry.click()
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
// Navigate back to parent
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// SubgraphNode should now have the promoted widget
|
||||
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
||||
})
|
||||
|
||||
@@ -216,7 +193,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
// First promote a canvas-rendered widget (KSampler "steps")
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
@@ -232,7 +208,6 @@ test.describe(
|
||||
|
||||
await expect(promoteEntry).toBeVisible()
|
||||
await promoteEntry.click()
|
||||
// Wait for the context menu to close, confirming the action completed.
|
||||
await expect(promoteEntry).toBeHidden()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
@@ -280,11 +255,9 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
|
||||
await expect(clipNode).toBeVisible()
|
||||
@@ -317,8 +290,6 @@ test.describe(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
|
||||
// The SaveImage node is in the recommendedNodes list, so its
|
||||
// filename_prefix widget should be auto-promoted
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
'5',
|
||||
@@ -331,7 +302,6 @@ test.describe(
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Pan to SaveImage node (rightmost, may be off-screen in CI)
|
||||
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
||||
await saveNode.centerOnNode()
|
||||
|
||||
@@ -339,7 +309,6 @@ test.describe(
|
||||
const subgraphNode = await saveNode.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// SaveImage is a recommended node, so filename_prefix should be promoted
|
||||
const nodeId = String(subgraphNode.id)
|
||||
await expectPromotedWidgetNamesToContain(
|
||||
comfyPage,
|
||||
@@ -356,7 +325,6 @@ test.describe(
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-preview-node'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(subgraphVueNode).toBeVisible()
|
||||
@@ -393,56 +361,48 @@ test.describe(
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Nested Promoted Widget Disabled State', () => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const disabledState = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('5')
|
||||
return (node?.widgets ?? []).map((w) => ({
|
||||
name: w.name,
|
||||
disabled: !!w.computedDisabled
|
||||
}))
|
||||
})
|
||||
return disabledState.find((w) => w.name === 'string_a')?.disabled
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const textareas = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await textareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = textareas.nth(i)
|
||||
const wrapper = textarea.locator('..')
|
||||
const opacity = await wrapper.evaluate(
|
||||
(el) => getComputedStyle(el).opacity
|
||||
test.describe(
|
||||
'Nested Promoted Widget Disabled State',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-nested-promotion'
|
||||
)
|
||||
|
||||
if (opacity === '1' && (await textarea.isEditable())) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
||||
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
const linkedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'string_a',
|
||||
exact: true
|
||||
})
|
||||
await expect(linkedTextarea).toBeVisible()
|
||||
await expect(linkedTextarea).toBeDisabled()
|
||||
|
||||
const allTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(allTextareas.first()).toBeVisible()
|
||||
|
||||
let editedTextarea = false
|
||||
const count = await allTextareas.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const textarea = allTextareas.nth(i)
|
||||
if (await textarea.isEditable()) {
|
||||
const testContent = `nested-promotion-edit-${i}`
|
||||
await textarea.fill(testContent)
|
||||
await expect(textarea).toHaveValue(testContent)
|
||||
editedTextarea = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
})
|
||||
expect(editedTextarea).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Promotion Cleanup', () => {
|
||||
test('Removing subgraph node clears promotion store entries', async ({
|
||||
@@ -452,16 +412,13 @@ test.describe(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Verify promotions exist
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
|
||||
.toEqual(expect.arrayContaining([expect.anything()]))
|
||||
|
||||
// Delete the subgraph node
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
|
||||
// Node no longer exists, so promoted widgets should be gone
|
||||
await expect.poll(() => subgraphNode.exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -520,17 +477,13 @@ test.describe(
|
||||
.toBeGreaterThan(0)
|
||||
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
||||
|
||||
// Navigate into subgraph
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Remove the text input slot
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
|
||||
// Navigate back via breadcrumb
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Widget count should be reduced
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
||||
.toBeLessThan(initialWidgetCount)
|
||||
@@ -588,7 +541,6 @@ test.describe(
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
||||
'CLIP Text Encode (Prompt)'
|
||||
@@ -608,216 +560,198 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
test(
|
||||
'Promote/Demote by Context Menu',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
await expect(steps).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await test.step('Un-promote widget', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
})
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(steps).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
test(
|
||||
'Properties panel operations',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: false
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
})
|
||||
|
||||
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'linked widgets are first by default'
|
||||
).toHaveText('steps')
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(
|
||||
editor.promotionItems.first(),
|
||||
'Swap widget order'
|
||||
).toContainText('cfg')
|
||||
|
||||
// FIXME: solve actual bug and remove the not
|
||||
await expect(
|
||||
subgraphNode.locator('.lg-node-widget').first(),
|
||||
'Linked widget is first on node'
|
||||
).not.toHaveText('cfg')
|
||||
})
|
||||
|
||||
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'cfg',
|
||||
toState: true
|
||||
})
|
||||
await expect(cfg, 'Promote widget').toBeVisible()
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
// FUTURE: Add test for re-ordering previews?
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
await test.step('widgets display in order promoted', async () => {
|
||||
await expect(editor.promotionItems.first()).toContainText('steps')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'steps'
|
||||
)
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await test.step('Reorder widgets', async () => {
|
||||
await editor.dragItem(0, 1)
|
||||
await expect(editor.promotionItems.first()).toContainText('cfg')
|
||||
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
||||
'cfg'
|
||||
)
|
||||
})
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
await expect(steps, 'Un-promote widget').toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
await editor.open(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
})
|
||||
test(
|
||||
'Link already promoted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
||||
|
||||
await editor.togglePromotion(subgraphNode, {
|
||||
nodeName: 'KSampler',
|
||||
widgetName: 'steps',
|
||||
toState: true
|
||||
})
|
||||
await expect(steps, 'Promote widget').toBeVisible()
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await expect(steps).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Can promote multiple previews',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await test.step('Add and rename a Load Image node', async () => {
|
||||
const position = { x: 300, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
await loadImage.setTitle('Character Reference')
|
||||
})
|
||||
|
||||
await test.step('Add a second Load Image node', async () => {
|
||||
const position = { x: 600, y: 300 }
|
||||
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
||||
})
|
||||
|
||||
await test.step('Convert both nodes to subgraph', async () => {
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.contextMenu
|
||||
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
})
|
||||
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
|
||||
await test.step('Promote both image previews', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '2',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: true
|
||||
})
|
||||
|
||||
await expect(subgraph.content).toHaveCount(2)
|
||||
})
|
||||
|
||||
await test.step('Demote image', async () => {
|
||||
await editor.togglePromotion(subgraph.root, {
|
||||
nodeId: '1',
|
||||
widgetName: '$$canvas-image-preview',
|
||||
toState: false
|
||||
})
|
||||
await expect(subgraph.content).toHaveCount(1)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Linked widgets can not be demoted',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const { editor } = comfyPage.subgraph
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
||||
|
||||
await test.step('Enter subgraph and link widget to input', async () => {
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
const fromSlot = ksampler.getSlot('steps')
|
||||
const toPos = await comfyPage.subgraph
|
||||
.getInputSlot()
|
||||
.getOpenSlotPosition()
|
||||
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
})
|
||||
|
||||
await editor.ensureOpen(subgraphNode)
|
||||
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
||||
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,8 +5,6 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
|
||||
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
@@ -31,133 +29,125 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
|
||||
test.describe(
|
||||
'Subgraph Promotion DOM',
|
||||
{ tag: ['@subgraph', '@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
await parentTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
||||
.toContain('seed')
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test.describe(
|
||||
'Promoted Text Widget Lifecycle',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
() => {
|
||||
test('Promoted text widget preserves content through subgraph enter/exit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextarea = subgraphNode.getByRole('textbox', {
|
||||
name: 'text'
|
||||
})
|
||||
await expect(promotedTextarea).toBeVisible()
|
||||
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.delete()
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
|
||||
})
|
||||
await comfyPage.keyboard.press('Escape')
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const backToPromoted = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(backToPromoted).toBeVisible()
|
||||
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
|
||||
test('Promoted text widget is removed when subgraph node is deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNodeRef.delete()
|
||||
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
})
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
await expect(
|
||||
subgraphNode.getByRole('textbox', { name: 'text' })
|
||||
).toBeVisible()
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
await comfyPage.subgraph.removeSlot('input', 'text')
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
|
||||
await expect(visibleWidgets).toHaveCount(2)
|
||||
const parentCount = await visibleWidgets.count()
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await expect
|
||||
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
|
||||
.toBe(true)
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
||||
)
|
||||
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
|
||||
const promotedTextareas = subgraphNode.getByRole('textbox')
|
||||
await expect(promotedTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
await openSubgraphById(comfyPage, '11')
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
const interiorTextareas = comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.getByRole('textbox')
|
||||
await expect(interiorTextareas).toHaveCount(2)
|
||||
|
||||
await expect(visibleWidgets).toHaveCount(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,87 @@ import {
|
||||
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
||||
const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
|
||||
'subgraphs/subgraph-primitive-fanout-multi-host'
|
||||
const UNRESOLVABLE_PROXY_WORKFLOW =
|
||||
'subgraphs/subgraph-unresolvable-proxy-widget'
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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.findNodesByType(
|
||||
'PrimitiveNode',
|
||||
[]
|
||||
)
|
||||
const primitiveOriginLinkCount = [
|
||||
...hostNode.subgraph._links.values()
|
||||
].filter((link) => link.origin_id === primitiveNode?.id).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,
|
||||
@@ -41,23 +122,173 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
|
||||
}
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
test(
|
||||
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(beforeReload).toHaveCount(1)
|
||||
await expect(beforeReload).toBeVisible()
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
||||
.toBeGreaterThan(1)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
|
||||
await expect(afterReload).toHaveCount(1)
|
||||
await expect(afterReload).toBeVisible()
|
||||
})
|
||||
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()
|
||||
|
||||
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
||||
|
||||
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',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
|
||||
)
|
||||
|
||||
const expectHostHasIndependentValues = async (
|
||||
hostId: string,
|
||||
stringValue: string,
|
||||
intValue: string
|
||||
) => {
|
||||
const host = comfyPage.vueNodes.getNodeLocator(hostId)
|
||||
const widgets = host.getByTestId(TestIds.widgets.widget)
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
|
||||
stringValue
|
||||
)
|
||||
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
|
||||
intValue
|
||||
)
|
||||
}
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
await expectHostHasIndependentValues('2', 'first-host', '11')
|
||||
await expectHostHasIndependentValues('12', 'second-host', '22')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Nested preview exposures render through serialized chain resolution',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
test.setTimeout(45_000)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-multiple-promoted-previews'
|
||||
)
|
||||
|
||||
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(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
|
||||
.toContain('$$canvas-image-preview')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Legacy unresolvable proxy entry is omitted and quarantined on save',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
|
||||
|
||||
const host = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(host).toBeVisible()
|
||||
await expect(host.getByText('missing_widget')).toHaveCount(0)
|
||||
|
||||
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',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
const beforeReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(beforeReload).toBeVisible()
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const afterReload = comfyPage.vueNodes
|
||||
.getNodeLocator('11')
|
||||
.getByRole('textbox', { name: 'text' })
|
||||
await expect(afterReload).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
||||
comfyPage
|
||||
@@ -413,39 +644,10 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for legacy-prefixed proxyWidget normalization.
|
||||
*
|
||||
* Older serialized workflows stored proxyWidget entries with prefixed widget
|
||||
* names like "6: 3: string_a" instead of plain "string_a". This caused
|
||||
* resolution failures during configure, resulting in missing promoted widgets.
|
||||
*
|
||||
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
|
||||
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
|
||||
* the promoted widget should render with the clean name "string_a".
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
|
||||
*/
|
||||
test.describe(
|
||||
'Legacy Prefixed proxyWidget Normalization',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
let previousVueNodesEnabled: unknown
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
previousVueNodesEnabled = await comfyPage.settings.getSetting(
|
||||
'Comfy.VueNodes.Enabled'
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
previousVueNodesEnabled
|
||||
)
|
||||
})
|
||||
|
||||
test('Loads without console warnings about failed widget resolution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -466,7 +668,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
@@ -482,19 +683,14 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
||||
await expect(outerNode).toBeVisible()
|
||||
|
||||
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:')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,12 +111,10 @@ 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')
|
||||
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
|
||||
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@@ -9,25 +9,23 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await expect(loadCheckpointNode).toHaveCount(1)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
node.addWidget('text', 'extra_widget_a', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
node.addWidget('text', 'extra_widget_b', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
node.addWidget('text', 'extra_widget_c', '', () => {})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -373,7 +373,8 @@ export default defineConfig([
|
||||
files: [
|
||||
'src/base/**/*.{ts,vue}',
|
||||
'src/platform/**/*.{ts,vue}',
|
||||
'src/workbench/**/*.{ts,vue}'
|
||||
'src/workbench/**/*.{ts,vue}',
|
||||
'src/world/**/*.{ts,vue}'
|
||||
],
|
||||
rules: {
|
||||
'import-x/no-restricted-paths': [
|
||||
@@ -401,6 +402,12 @@ export default defineConfig([
|
||||
from: './src/renderer/**',
|
||||
message:
|
||||
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
|
||||
},
|
||||
{
|
||||
target: './src/world/**',
|
||||
from: './src/lib/litegraph/**',
|
||||
message:
|
||||
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -28,7 +30,6 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||
@@ -47,26 +48,8 @@ const hoveringSelectable = ref(false)
|
||||
|
||||
workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
subLabel: t('linearMode.builder.unknownWidget')
|
||||
}
|
||||
}
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
appModeStore.selectedOutputs.map((nodeId) => [
|
||||
nodeId,
|
||||
@@ -74,16 +57,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -102,22 +75,26 @@ function getHovered(
|
||||
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
|
||||
}
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
function getNodeBounding(nodeId: NodeId) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
if (!widgetName)
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
if (!widget) return
|
||||
return {
|
||||
width: `${node.size[0]}px`,
|
||||
height: `${node.size[1] + titleOffset}px`,
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
if (entry.status !== 'resolved') return undefined
|
||||
const { node, widget } = entry
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
const marginX = margin ?? BaseWidget.margin
|
||||
@@ -133,6 +110,11 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedEntityId(entityId: WidgetEntityId): void {
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
function handleDown(e: MouseEvent) {
|
||||
const [node] = getHovered(e) ?? []
|
||||
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
|
||||
@@ -157,14 +139,11 @@ 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 index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
const entityId = widget.entityId
|
||||
if (!entityId) return
|
||||
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
|
||||
if (index === -1)
|
||||
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -173,7 +152,7 @@ function nodeToDisplayTuple(
|
||||
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
|
||||
return [
|
||||
n.id,
|
||||
getBounding(n.id),
|
||||
getNodeBounding(n.id),
|
||||
appModeStore.selectedOutputs.some((id) => n.id === id)
|
||||
]
|
||||
}
|
||||
@@ -191,10 +170,13 @@ const renderedOutputs = computed(() => {
|
||||
})
|
||||
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
||||
`${nodeId}: ${widgetName}`,
|
||||
getBounding(nodeId, widgetName)
|
||||
])
|
||||
resolvedInputs.value.map(
|
||||
(entry) =>
|
||||
[entry.entityId, getWidgetBounding(entry)] as [
|
||||
string,
|
||||
MaybeRef<BoundStyle> | undefined
|
||||
]
|
||||
)
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
@@ -238,30 +220,28 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
>
|
||||
<IoItem
|
||||
v-for="{
|
||||
nodeId,
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
appModeStore.selectedInputs,
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
<template v-for="entry in resolvedInputs" :key="entry.entityId">
|
||||
<IoItem
|
||||
v-if="entry.status === 'resolved'"
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.widget.label ?? entry.displayName"
|
||||
:sub-title="entry.node.title"
|
||||
can-rename
|
||||
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
|
||||
@rename="renameWidget(entry.widget, entry.node, $event)"
|
||||
/>
|
||||
<IoItem
|
||||
v-else
|
||||
:class="
|
||||
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
||||
"
|
||||
:title="entry.displayName"
|
||||
:sub-title="t('linearMode.builder.unknownWidget')"
|
||||
:remove="() => removeSelectedEntityId(entry.entityId)"
|
||||
/>
|
||||
</template>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
|
||||
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
@@ -23,15 +22,12 @@ import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
persistedHeight: number | undefined
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
@@ -49,31 +45,25 @@ const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const { onPointerDown } = useAppModeWidgetResizing(
|
||||
(nodeId, widgetName, config) =>
|
||||
appModeStore.updateInputConfig(nodeId, widgetName, config)
|
||||
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
appModeStore.updateInputConfig(widget, config)
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
return resolvedInputs.value.flatMap((entry) => {
|
||||
if (entry.status !== 'resolved') return []
|
||||
const { entityId, node, widget, config } = entry
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
@@ -82,15 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
return vueWidget.entityId === entityId
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
@@ -99,9 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeId,
|
||||
widgetName,
|
||||
key: entityId,
|
||||
persistedHeight: config?.height,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
@@ -168,14 +148,7 @@ defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{
|
||||
key,
|
||||
nodeId,
|
||||
widgetName,
|
||||
persistedHeight,
|
||||
nodeData,
|
||||
action
|
||||
} in mappedSelections"
|
||||
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
@@ -223,8 +196,7 @@ defineExpose({ handleDragDrop })
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
command: () => appModeStore.removeSelectedInput(action.widget)
|
||||
}
|
||||
]"
|
||||
>
|
||||
@@ -253,7 +225,7 @@ defineExpose({ handleDragDrop })
|
||||
)
|
||||
"
|
||||
:inert="builderMode || undefined"
|
||||
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
|
||||
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
|
||||
|
||||
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
|
||||
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
|
||||
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
|
||||
|
||||
function setHeight(el: HTMLElement, height: number) {
|
||||
Object.defineProperty(el, 'offsetHeight', {
|
||||
value: height,
|
||||
@@ -28,15 +32,13 @@ function wrapWithTextarea(initialHeight = 100): {
|
||||
describe('useAppModeWidgetResizing', () => {
|
||||
function setup() {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const { onPointerDown } = useAppModeWidgetResizing(onResize)
|
||||
|
||||
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
|
||||
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
|
||||
(e) => onPointerDown(widget, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
}
|
||||
@@ -47,19 +49,19 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists height when textarea is resized via drag', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('does not persist when no height change occurs (e.g. a click)', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -70,7 +72,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
it('persists once per drag gesture; stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -86,7 +88,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const button = document.createElement('button')
|
||||
wrapper.appendChild(button)
|
||||
document.body.appendChild(wrapper)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
@@ -104,21 +106,21 @@ describe('useAppModeWidgetResizing', () => {
|
||||
wrapper.appendChild(indicator)
|
||||
document.body.appendChild(wrapper)
|
||||
setHeight(indicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'image')
|
||||
bind(wrapper, WIDGET_IMAGE)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(indicator, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
|
||||
})
|
||||
|
||||
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const first = wrapWithTextarea()
|
||||
const second = wrapWithTextarea()
|
||||
bind(first.wrapper, 1 as NodeId, 'prompt')
|
||||
bind(second.wrapper, 2 as NodeId, 'other')
|
||||
bind(first.wrapper, WIDGET_PROMPT)
|
||||
bind(second.wrapper, WIDGET_OTHER)
|
||||
|
||||
first.textarea.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
@@ -132,25 +134,25 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
|
||||
})
|
||||
|
||||
it('treats pointercancel as the end of a gesture and persists the new height', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
window.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
|
||||
const { bind, onResize } = setup()
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(textarea, 250)
|
||||
@@ -159,14 +161,12 @@ describe('useAppModeWidgetResizing', () => {
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(onResize).toHaveBeenCalledTimes(1)
|
||||
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
|
||||
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
|
||||
})
|
||||
|
||||
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
|
||||
const onResize =
|
||||
vi.fn<
|
||||
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
|
||||
>()
|
||||
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
|
||||
const scope = effectScope()
|
||||
const { onPointerDown } = scope.run(() =>
|
||||
useAppModeWidgetResizing(onResize)
|
||||
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
const { wrapper, textarea } = wrapWithTextarea()
|
||||
wrapper.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
|
||||
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
|
||||
{ capture: true }
|
||||
)
|
||||
|
||||
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
|
||||
outerIndicator.appendChild(wrapper)
|
||||
document.body.appendChild(outerIndicator)
|
||||
setHeight(outerIndicator, 100)
|
||||
bind(wrapper, 1 as NodeId, 'prompt')
|
||||
bind(wrapper, WIDGET_PROMPT)
|
||||
|
||||
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
setHeight(outerIndicator, 250)
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
|
||||
|
||||
export function useAppModeWidgetResizing(
|
||||
onResize: (
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
config: InputWidgetConfig
|
||||
) => void
|
||||
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
|
||||
) {
|
||||
let pendingHandler: (() => void) | null = null
|
||||
|
||||
@@ -23,11 +19,7 @@ export function useAppModeWidgetResizing(
|
||||
|
||||
onScopeDispose(clearPendingHandler)
|
||||
|
||||
function onPointerDown(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
event: PointerEvent
|
||||
) {
|
||||
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
|
||||
const wrapper = event.currentTarget
|
||||
const target = event.target
|
||||
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
|
||||
@@ -44,7 +36,7 @@ export function useAppModeWidgetResizing(
|
||||
pendingHandler = null
|
||||
const height = resizable.offsetHeight
|
||||
if (height === startHeight) return
|
||||
onResize(nodeId, widgetName, { height })
|
||||
onResize(widget, { height })
|
||||
}
|
||||
pendingHandler = handler
|
||||
window.addEventListener('pointerup', handler)
|
||||
|
||||
91
src/components/builder/useResolvedSelectedInputs.test.ts
Normal file
91
src/components/builder/useResolvedSelectedInputs.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
nodes: [] as LGraphNode[],
|
||||
events: new EventTarget(),
|
||||
getNodeById: vi.fn() as (id: number) => LGraphNode | null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function setRootGraphNodes(nodes: LGraphNode[]) {
|
||||
vi.mocked(app.rootGraph).nodes = nodes
|
||||
vi.mocked(app.rootGraph).getNodeById = vi.fn(
|
||||
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function dispatchRootGraphEvent(type: string) {
|
||||
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
|
||||
new Event(type)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useResolvedSelectedInputs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
setRootGraphNodes([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('convert-to-subgraph')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
|
||||
const node = makeNode(1, ['seed'])
|
||||
setRootGraphNodes([node])
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
appModeStore.selectedInputs = [[entitySeed, 'seed']]
|
||||
|
||||
const resolved = useResolvedSelectedInputs()
|
||||
expect(resolved.value[0]?.status).toBe('resolved')
|
||||
|
||||
setRootGraphNodes([])
|
||||
dispatchRootGraphEvent('subgraph-created')
|
||||
|
||||
expect(resolved.value[0]?.status).toBe('unknown')
|
||||
})
|
||||
})
|
||||
71
src/components/builder/useResolvedSelectedInputs.ts
Normal file
71
src/components/builder/useResolvedSelectedInputs.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export type ResolvedSelection =
|
||||
| {
|
||||
status: 'resolved'
|
||||
entityId: WidgetEntityId
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
| {
|
||||
status: 'unknown'
|
||||
entityId: WidgetEntityId
|
||||
displayName: string
|
||||
config?: InputWidgetConfig
|
||||
}
|
||||
|
||||
export function useResolvedSelectedInputs() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
|
||||
const refreshGraphNodes = () =>
|
||||
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
|
||||
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'convert-to-subgraph',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'subgraph-created',
|
||||
refreshGraphNodes
|
||||
)
|
||||
useEventListener(
|
||||
() => app.rootGraph?.events,
|
||||
'node:slot-label:changed',
|
||||
() => triggerRef(graphNodes)
|
||||
)
|
||||
|
||||
return computed<ResolvedSelection[]>(() => {
|
||||
void graphNodes.value
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return []
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(
|
||||
([entityId, displayName, config]): ResolvedSelection[] => {
|
||||
if (!isWidgetEntityId(entityId)) return []
|
||||
const { nodeId, name } = parseWidgetEntityId(entityId)
|
||||
const node = rootGraph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === name)
|
||||
if (!node || !widget) {
|
||||
return [{ status: 'unknown', entityId, displayName, config }]
|
||||
}
|
||||
return [
|
||||
{ status: 'resolved', entityId, node, widget, displayName, config }
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
onBeforeUnmount,
|
||||
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 { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -54,12 +64,74 @@ const {
|
||||
|
||||
const collapse = defineModel<boolean>('collapse', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
reorder: [event: { fromIndex: number; toIndex: number }]
|
||||
}>()
|
||||
|
||||
const widgetsContainer = ref<HTMLElement>()
|
||||
const rootElement = ref<HTMLElement>()
|
||||
|
||||
const widgets = shallowRef(widgetsProp)
|
||||
watchEffect(() => (widgets.value = widgetsProp))
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>()
|
||||
const isMounted = useMounted()
|
||||
|
||||
function setDraggableState() {
|
||||
draggableList.value?.dispose()
|
||||
draggableList.value = undefined
|
||||
|
||||
if (!isMounted.value || !isDraggable || collapse.value) return
|
||||
const container = widgetsContainer.value
|
||||
if (!container?.children?.length) return
|
||||
|
||||
const list = new DraggableList(container, '.draggable-item')
|
||||
|
||||
list.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[SectionWidgets] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
if (typeof reorderedItems[index] === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
emit('reorder', { fromIndex: oldPosition, toIndex: newPosition })
|
||||
}
|
||||
|
||||
draggableList.value = list
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
[widgets, () => isDraggable, collapse],
|
||||
() => setDraggableState(),
|
||||
{ debounce: 100, immediate: true }
|
||||
)
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -70,8 +142,6 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
const promotionStore = usePromotionStore()
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
@@ -83,13 +153,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
|
||||
})
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef
|
||||
} from 'vue'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { DraggableList } from '@/scripts/ui/draggableList'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -25,8 +16,6 @@ const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
const { t } = useI18n()
|
||||
|
||||
const draggableList = ref<DraggableList | undefined>(undefined)
|
||||
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
|
||||
const isSearching = ref(false)
|
||||
|
||||
const favoritedWidgets = computed(
|
||||
@@ -48,70 +37,20 @@ async function searcher(query: string) {
|
||||
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
function handleReorder({
|
||||
fromIndex,
|
||||
toIndex
|
||||
}: {
|
||||
fromIndex: number
|
||||
toIndex: number
|
||||
}) {
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [moved] = widgets.splice(fromIndex, 1)
|
||||
if (!moved) return
|
||||
widgets.splice(toIndex, 0, moved)
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
const widgets = [...searchedFavoritedWidgets.value]
|
||||
const [widget] = widgets.splice(oldPosition, 1)
|
||||
widgets.splice(newPosition, 0, widget)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
searchedFavoritedWidgets,
|
||||
() => {
|
||||
setDraggableState()
|
||||
},
|
||||
{ debounce: 100 }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setDraggableState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
draggableList.value?.dispose()
|
||||
})
|
||||
|
||||
function onCollapseUpdate() {
|
||||
// Rebuild draggable list after the section header is toggled
|
||||
nextTick(setDraggableState)
|
||||
searchedFavoritedWidgets.value = widgets
|
||||
favoritedWidgetsStore.reorderFavorites(widgets)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -127,7 +66,6 @@ function onCollapseUpdate() {
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:label
|
||||
:widgets="searchedFavoritedWidgets"
|
||||
:is-draggable="!isSearching"
|
||||
@@ -135,7 +73,7 @@ function onCollapseUpdate() {
|
||||
show-node-name
|
||||
enable-empty-state
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="onCollapseUpdate"
|
||||
@reorder="handleReorder"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useMounted, watchDebounced } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useTemplateRef,
|
||||
watch
|
||||
} from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} 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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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 +26,6 @@ const { node } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
@@ -51,13 +43,33 @@ const isAllCollapsed = computed({
|
||||
advancedInputsCollapsed.value = collapse
|
||||
}
|
||||
})
|
||||
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 +93,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 +108,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)
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -146,66 +125,22 @@ async function searcher(query: string) {
|
||||
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
|
||||
}
|
||||
|
||||
const isMounted = useMounted()
|
||||
function handleReorder({
|
||||
fromIndex,
|
||||
toIndex
|
||||
}: {
|
||||
fromIndex: number
|
||||
toIndex: number
|
||||
}) {
|
||||
const widgets = searchedWidgetsList.value.map((row) => row.widget)
|
||||
const [moved] = widgets.splice(fromIndex, 1)
|
||||
if (!moved) return
|
||||
widgets.splice(toIndex, 0, moved)
|
||||
|
||||
function setDraggableState() {
|
||||
if (!isMounted.value) return
|
||||
|
||||
draggableList.value?.dispose()
|
||||
const container = sectionWidgetsRef.value?.widgetsContainer
|
||||
if (isSearching.value || !container?.children?.length) return
|
||||
|
||||
draggableList.value = new DraggableList(container, '.draggable-item')
|
||||
|
||||
draggableList.value.applyNewItemsOrder = function () {
|
||||
const reorderedItems: HTMLElement[] = []
|
||||
|
||||
let oldPosition = -1
|
||||
this.getAllItems().forEach((item, index) => {
|
||||
if (item === this.draggableItem) {
|
||||
oldPosition = index
|
||||
return
|
||||
}
|
||||
if (!this.isItemToggled(item)) {
|
||||
reorderedItems[index] = item
|
||||
return
|
||||
}
|
||||
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
|
||||
reorderedItems[newIndex] = item
|
||||
})
|
||||
|
||||
if (oldPosition === -1) {
|
||||
console.error('[TabSubgraphInputs] draggableItem not found in items')
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < this.getAllItems().length; index++) {
|
||||
const item = reorderedItems[index]
|
||||
if (typeof item === 'undefined') {
|
||||
reorderedItems[index] = this.draggableItem as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
const newPosition = reorderedItems.indexOf(
|
||||
this.draggableItem as HTMLElement
|
||||
)
|
||||
|
||||
promotionStore.movePromotion(
|
||||
node.rootGraph.id,
|
||||
node.id,
|
||||
oldPosition,
|
||||
newPosition
|
||||
)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
reorderSubgraphInputsByWidgetOrder(node, widgets)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
|
||||
debounce: 100
|
||||
})
|
||||
onMounted(() => setDraggableState())
|
||||
onBeforeUnmount(() => draggableList.value?.dispose())
|
||||
|
||||
const label = computed(() => {
|
||||
return searchedWidgetsList.value.length !== 0
|
||||
? t('rightSidePanel.inputs')
|
||||
@@ -229,7 +164,6 @@ const label = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<SectionWidgets
|
||||
ref="sectionWidgetsRef"
|
||||
:collapse="firstSectionCollapsed && !isSearching"
|
||||
:node
|
||||
:label
|
||||
@@ -243,12 +177,8 @@ const label = computed(() => {
|
||||
: t('rightSidePanel.inputsNoneTooltip')
|
||||
"
|
||||
class="border-b border-interface-stroke"
|
||||
@update:collapse="
|
||||
(v) => {
|
||||
firstSectionCollapsed = v
|
||||
nextTick(setDraggableState)
|
||||
}
|
||||
"
|
||||
@update:collapse="(v) => (firstSectionCollapsed = v)"
|
||||
@reorder="handleReorder"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -9,15 +9,19 @@ 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(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
|
||||
demoteWidget: vi.fn(),
|
||||
promoteWidget: vi.fn(),
|
||||
isLinkedPromotion: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
getInputSpecForWidget: mockGetInputSpecForWidget
|
||||
@@ -201,64 +205,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 {
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
getControlWidget: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
|
||||
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
|
||||
resolvePromotedWidgetSource: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { st } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
265
src/components/rightSidePanel/subgraph/SubgraphEditor.test.ts
Normal file
265
src/components/rightSidePanel/subgraph/SubgraphEditor.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
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 { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import SubgraphEditor from './SubgraphEditor.vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import type DraggableList from '@/components/common/DraggableList.vue'
|
||||
|
||||
type DraggableListProps = ComponentProps<typeof DraggableList>
|
||||
type PromotedRow =
|
||||
DraggableListProps['modelValue'] extends Array<infer T> ? T : never
|
||||
|
||||
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('updates rendered order when promoted widgets are reordered', 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]
|
||||
|
||||
let listSetter: ((value: PromotedRow[]) => void) | undefined
|
||||
const draggableListStub = {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
_: unknown,
|
||||
{
|
||||
emit,
|
||||
slots
|
||||
}: {
|
||||
emit: (event: string, ...args: unknown[]) => void
|
||||
slots: { default?: (props: { dragClass: string }) => unknown }
|
||||
}
|
||||
) {
|
||||
listSetter = (value) => emit('update:modelValue', value)
|
||||
return () => slots.default?.({ dragClass: 'draggable-item' })
|
||||
}
|
||||
}
|
||||
render(SubgraphEditor, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { DraggableList: draggableListStub }
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['first', 'second'])
|
||||
|
||||
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
|
||||
const reversed = [
|
||||
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
|
||||
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
|
||||
] as PromotedRow[]
|
||||
listSetter?.(reversed)
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
within(shown)
|
||||
.getAllByTestId('subgraph-widget-label')
|
||||
.map((el) => el.textContent?.trim())
|
||||
).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('demotes linked promoted widgets when "Hide all" is clicked', 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: {
|
||||
template:
|
||||
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const hideAllLink = within(shown).getByText('Hide all')
|
||||
await userEvent.click(hideAllLink)
|
||||
|
||||
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const orphanedSourceNode = new LGraphNode('OrphanedNode')
|
||||
orphanedSourceNode.type = 'OrphanedNode'
|
||||
subgraph.add(orphanedSourceNode)
|
||||
|
||||
const previewStore = usePreviewExposureStore()
|
||||
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: String(orphanedSourceNode.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>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
|
||||
const shown = screen.getByTestId('subgraph-editor-shown-section')
|
||||
const toggleButton = within(shown).getByTestId('subgraph-widget-toggle')
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
expect(
|
||||
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getSourceNodeId,
|
||||
getWidgetName,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.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()
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
widget: PromotedWidgetView
|
||||
}
|
||||
type PreviewRow = {
|
||||
kind: 'preview'
|
||||
node: LGraphNode
|
||||
exposure: PreviewExposure
|
||||
realWidget?: IBaseWidget
|
||||
}
|
||||
type ActiveRow = PromotedRow | PreviewRow
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const promotionEntries = computed(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return promotionStore.getPromotions(node.rootGraph.id, node.id)
|
||||
})
|
||||
|
||||
const activeNode = computed(() => {
|
||||
const node = canvasStore.selectedItems[0]
|
||||
if (node instanceof SubgraphNode) return node
|
||||
return undefined
|
||||
})
|
||||
|
||||
const activeWidgets = computed<WidgetItem[]>({
|
||||
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
|
||||
function refreshPromotedWidgets() {
|
||||
promotedWidgets.value = activeNode.value?.widgets ?? []
|
||||
}
|
||||
watch(activeNode, refreshPromotedWidgets, { immediate: true })
|
||||
useEventListener(
|
||||
() => activeNode.value?.subgraph.events,
|
||||
[
|
||||
'widget-promoted',
|
||||
'widget-demoted',
|
||||
'input-added',
|
||||
'removing-input',
|
||||
'inputs-reordered'
|
||||
],
|
||||
refreshPromotedWidgets
|
||||
)
|
||||
|
||||
const activeRows = computed<ActiveRow[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
|
||||
})
|
||||
|
||||
const activePromotedRows = computed<PromotedRow[]>({
|
||||
get() {
|
||||
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 node ? getActivePromotedRows(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()
|
||||
set(value: PromotedRow[]) {
|
||||
updateActivePromotedRows(value, activePromotedRows.value)
|
||||
}
|
||||
})
|
||||
|
||||
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
|
||||
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
|
||||
if (!isPromotedWidgetView(widget)) return []
|
||||
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
return [{ kind: 'promoted', node: sourceNode, widget }]
|
||||
})
|
||||
}
|
||||
|
||||
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
|
||||
const hostLocator = String(node.id)
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
|
||||
return exposures.flatMap((exposure): PreviewRow[] => {
|
||||
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
|
||||
if (!sourceNode) return []
|
||||
const realWidget = getPromotableWidgets(sourceNode).find(
|
||||
(candidate) => candidate.name === exposure.sourcePreviewName
|
||||
)
|
||||
return [{ kind: 'preview', node: sourceNode, exposure, realWidget }]
|
||||
})
|
||||
}
|
||||
|
||||
function updateActivePromotedRows(
|
||||
value: PromotedRow[],
|
||||
currentItems: PromotedRow[]
|
||||
) {
|
||||
const node = activeNode.value
|
||||
if (!node) {
|
||||
console.error('Attempted to toggle widgets with no node selected')
|
||||
return
|
||||
}
|
||||
const currentKeys = new Set(currentItems.map(promotedRowKey))
|
||||
const nextKeys = new Set(value.map(promotedRowKey))
|
||||
for (const item of value) {
|
||||
if (!currentKeys.has(promotedRowKey(item))) promotePromotedRow(item)
|
||||
}
|
||||
for (const item of currentItems) {
|
||||
if (!nextKeys.has(promotedRowKey(item))) demoteRow(item)
|
||||
}
|
||||
if (currentKeys.size === nextKeys.size) {
|
||||
reorderSubgraphInputsByWidgetOrder(
|
||||
node,
|
||||
value.map((row) => row.widget)
|
||||
)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
|
||||
const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
@@ -115,18 +150,18 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
|
||||
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
|
||||
})
|
||||
|
||||
function activeRowSourceKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
|
||||
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
|
||||
}
|
||||
|
||||
const candidateWidgets = computed<WidgetItem[]>(() => {
|
||||
const node = activeNode.value
|
||||
if (!node) return []
|
||||
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
|
||||
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
|
||||
})
|
||||
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
|
||||
)
|
||||
})
|
||||
const filteredCandidates = computed<WidgetItem[]>(() => {
|
||||
@@ -145,16 +180,31 @@ const recommendedWidgets = computed(() => {
|
||||
return filteredCandidates.value.filter(isRecommendedWidget)
|
||||
})
|
||||
|
||||
const filteredActive = computed<WidgetItem[]>(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return activeWidgets.value
|
||||
return activeWidgets.value.filter(
|
||||
([n, w]: WidgetItem) =>
|
||||
n.title.toLowerCase().includes(query) ||
|
||||
w.name.toLowerCase().includes(query)
|
||||
function rowMatchesQuery(row: ActiveRow, query: string): boolean {
|
||||
return (
|
||||
row.node.title.toLowerCase().includes(query) ||
|
||||
rowDisplayName(row).toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
const filteredActive = computed<ActiveRow[]>(() => {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
if (!query) return activeRows.value
|
||||
return activeRows.value.filter((row) => rowMatchesQuery(row, query))
|
||||
})
|
||||
|
||||
const filteredActivePromoted = computed<PromotedRow[]>(() =>
|
||||
filteredActive.value.filter(
|
||||
(row): row is PromotedRow => row.kind === 'promoted'
|
||||
)
|
||||
)
|
||||
|
||||
const filteredActivePreviews = computed<PreviewRow[]>(() =>
|
||||
filteredActive.value.filter(
|
||||
(row): row is PreviewRow => row.kind === 'preview'
|
||||
)
|
||||
)
|
||||
|
||||
function refreshPromotedWidgetRendering() {
|
||||
const node = activeNode.value
|
||||
if (!node) return
|
||||
@@ -164,57 +214,89 @@ function refreshPromotedWidgetRendering() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function isItemLinked([node, widget]: WidgetItem): boolean {
|
||||
function rowDisplayName(row: ActiveRow): string {
|
||||
if (row.kind === 'promoted') {
|
||||
return row.widget.label || row.widget.name
|
||||
}
|
||||
return (
|
||||
node.id === -1 ||
|
||||
(!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(node.id),
|
||||
getWidgetName(widget)
|
||||
))
|
||||
row.realWidget?.label ||
|
||||
row.realWidget?.name ||
|
||||
row.exposure.sourcePreviewName
|
||||
)
|
||||
}
|
||||
|
||||
function toKey(item: WidgetItem) {
|
||||
const sid = getSourceNodeId(item[1])
|
||||
return sid
|
||||
? `${item[0].id}: ${item[1].name}:${sid}`
|
||||
: `${item[0].id}: ${item[1].name}`
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === -1) return true
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
isLinkedPromotion(
|
||||
activeNode.value,
|
||||
String(row.node.id),
|
||||
row.widget.sourceWidgetName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
|
||||
}
|
||||
|
||||
function rowKey(row: ActiveRow): string {
|
||||
return row.kind === 'promoted'
|
||||
? promotedRowKey(row)
|
||||
: `${row.node.id}: ${row.exposure.name}`
|
||||
}
|
||||
|
||||
function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w) => [n, w])
|
||||
}
|
||||
function demote([node, widget]: WidgetItem) {
|
||||
|
||||
function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
demoteWidget(node, widget, [subgraphNode])
|
||||
if (row.kind === 'promoted') {
|
||||
demoteWidget(row.node, row.widget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
if (row.realWidget) {
|
||||
demoteWidget(row.node, row.realWidget, [subgraphNode])
|
||||
return
|
||||
}
|
||||
previewExposureStore.removeExposure(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id),
|
||||
row.exposure.name
|
||||
)
|
||||
refreshPromotedWidgetRendering()
|
||||
}
|
||||
function promote([node, widget]: WidgetItem) {
|
||||
|
||||
function promotePromotedRow(row: PromotedRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(row.node, row.widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function promoteCandidate([node, widget]: WidgetItem) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
promoteWidget(node, widget, [subgraphNode])
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
for (const item of filteredCandidates.value) {
|
||||
promote(item)
|
||||
promoteCandidate(item)
|
||||
}
|
||||
}
|
||||
function hideAll() {
|
||||
const node = activeNode.value
|
||||
for (const item of filteredActive.value) {
|
||||
if (String(item[0].id) === '-1') continue
|
||||
if (
|
||||
node &&
|
||||
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
|
||||
)
|
||||
continue
|
||||
demote(item)
|
||||
for (const row of filteredActive.value) {
|
||||
if (String(row.node.id) === '-1') continue
|
||||
demoteRow(row)
|
||||
}
|
||||
}
|
||||
function showRecommended() {
|
||||
for (const item of recommendedWidgets.value) {
|
||||
promote(item)
|
||||
promoteCandidate(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,19 +341,34 @@ onMounted(() => {
|
||||
{{ $t('subgraphStore.hideAll') }}</a
|
||||
>
|
||||
</div>
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
|
||||
<DraggableList v-slot="{ dragClass }" v-model="activePromotedRows">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredActive"
|
||||
:key="toKey([node, widget])"
|
||||
:data-nodeid="node.id"
|
||||
v-for="row in filteredActivePromoted"
|
||||
:key="rowKey(row)"
|
||||
:data-nodeid="row.node.id"
|
||||
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.label || widget.name"
|
||||
:is-physical="isItemLinked([node, widget])"
|
||||
:node-title="row.node.title"
|
||||
:widget-name="rowDisplayName(row)"
|
||||
:is-physical="isRowLinked(row)"
|
||||
:is-draggable="!searchQuery"
|
||||
@toggle-visibility="demote([node, widget])"
|
||||
is-shown
|
||||
@toggle-visibility="demoteRow(row)"
|
||||
/>
|
||||
</DraggableList>
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="row in filteredActivePreviews"
|
||||
:key="rowKey(row)"
|
||||
:data-nodeid="row.node.id"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="row.node.title"
|
||||
:widget-name="rowDisplayName(row)"
|
||||
:is-physical="isRowLinked(row)"
|
||||
:is-draggable="false"
|
||||
is-shown
|
||||
@toggle-visibility="demoteRow(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -295,12 +392,12 @@ onMounted(() => {
|
||||
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
|
||||
<SubgraphNodeWidget
|
||||
v-for="[node, widget] in filteredCandidates"
|
||||
:key="toKey([node, widget])"
|
||||
:key="`${node.id}:${widget.name}`"
|
||||
:data-nodeid="node.id"
|
||||
class="bg-comfy-menu-bg"
|
||||
:node-title="node.title"
|
||||
:widget-name="widget.name"
|
||||
@toggle-visibility="promote([node, widget])"
|
||||
@toggle-visibility="promoteCandidate([node, widget])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,22 +10,22 @@ const {
|
||||
widgetName,
|
||||
isDraggable = false,
|
||||
isPhysical = false,
|
||||
isShown = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
nodeTitle: string
|
||||
widgetName: string
|
||||
isDraggable?: boolean
|
||||
isPhysical?: boolean
|
||||
isShown?: boolean
|
||||
class?: ClassValue
|
||||
}>()
|
||||
defineEmits<{
|
||||
(e: 'toggleVisibility'): void
|
||||
}>()
|
||||
defineEmits<{ toggleVisibility: [] }>()
|
||||
|
||||
const icon = computed(() =>
|
||||
isPhysical
|
||||
? 'icon-[lucide--link]'
|
||||
: isDraggable
|
||||
: isShown
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
)
|
||||
@@ -65,6 +65,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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { computed, nextTick, watch } from 'vue'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -16,7 +17,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', () => {
|
||||
@@ -102,12 +102,15 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
// Associate the input slot with the widget (as widgetInputs extension does)
|
||||
input.widget = { name: 'prompt' }
|
||||
|
||||
// Start with a connected link
|
||||
input.link = 42
|
||||
|
||||
graph.add(node)
|
||||
return { graph, node }
|
||||
|
||||
const upstream = new LGraphNode('upstream')
|
||||
upstream.addOutput('out', 'STRING')
|
||||
graph.add(upstream)
|
||||
const link = upstream.connect(0, node, 0)
|
||||
if (!link) throw new Error('Expected upstream.connect to produce a link')
|
||||
|
||||
return { graph, node, upstream, linkId: link.id }
|
||||
}
|
||||
|
||||
it('sets slotMetadata.linked to true when input has a link', () => {
|
||||
@@ -187,7 +190,28 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
||||
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'prompt', type: 'STRING' }]
|
||||
})
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
subgraph.add(node)
|
||||
|
||||
const link = subgraph.inputNode.slots[0].connect(input, node)
|
||||
if (!link)
|
||||
throw new Error('Expected SubgraphInput.connect to produce a link')
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(subgraph)
|
||||
const nodeData = vueNodeData.get(String(node.id))
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
|
||||
// Set up a subgraph with an interior node that has a "prompt" widget.
|
||||
// createPromotedWidgetView resolves against this interior node.
|
||||
const subgraph = createTestSubgraph()
|
||||
@@ -207,7 +231,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
@@ -218,7 +241,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
hostNode.widgets = [promotedView]
|
||||
const input = hostNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
input.link = 42
|
||||
graph.add(hostNode)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
@@ -229,21 +251,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
expect(widgetData).toBeDefined()
|
||||
expect(widgetData?.slotName).toBe('value')
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
|
||||
// Disconnect
|
||||
hostNode.inputs[0].link = null
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: hostNode.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
expect(widgetData?.slotMetadata).toBeDefined()
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
@@ -403,37 +411,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 }))
|
||||
@@ -471,122 +448,49 @@ describe('Nested promoted widget mapping', () => {
|
||||
|
||||
expect(mappedWidget).toBeDefined()
|
||||
expect(mappedWidget?.type).toBe('combo')
|
||||
expect(mappedWidget?.storeName).toBe('picker')
|
||||
expect(mappedWidget?.storeNodeId).toBe(
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
expect(mappedWidget?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
inputs: [
|
||||
{ name: 'first_seed', type: '*' },
|
||||
{ name: 'second_seed', 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 firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined)
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined)
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
|
||||
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'
|
||||
const widgets = nodeData?.widgets
|
||||
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets?.[0]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
|
||||
)
|
||||
|
||||
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}`
|
||||
])
|
||||
expect(widgets?.[1]?.entityId).toBe(
|
||||
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
|
||||
)
|
||||
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetSource
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
@@ -27,6 +29,8 @@ import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
LGraph,
|
||||
@@ -56,10 +60,9 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
*/
|
||||
export interface SafeWidgetData {
|
||||
entityId?: WidgetEntityId
|
||||
nodeId?: NodeId
|
||||
storeNodeId?: NodeId
|
||||
name: string
|
||||
storeName?: string
|
||||
type: string
|
||||
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
@@ -228,18 +231,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +257,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
|
||||
@@ -307,8 +306,7 @@ function safeWidgetMapper(
|
||||
? resolveConcretePromotedWidget(
|
||||
node,
|
||||
promotedSource.sourceNodeId,
|
||||
promotedSource.sourceWidgetName,
|
||||
promotedSource.disambiguatingSourceNodeId
|
||||
promotedSource.sourceWidgetName
|
||||
)
|
||||
: null
|
||||
const resolvedSource =
|
||||
@@ -321,24 +319,21 @@ 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
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
const sourceWidgetName = isPromotedWidgetView(widget)
|
||||
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
|
||||
: undefined
|
||||
const name = storeName ?? displayName
|
||||
const name = sourceWidgetName ?? displayName
|
||||
|
||||
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
|
||||
|
||||
return {
|
||||
entityId: getWidgetEntityIdForNode(node, widget),
|
||||
nodeId,
|
||||
storeNodeId: nodeId,
|
||||
name,
|
||||
storeName,
|
||||
type: effectiveWidget.type,
|
||||
...sharedEnhancements,
|
||||
callback,
|
||||
@@ -386,10 +381,10 @@ function buildSlotMetadata(
|
||||
|
||||
if (input.link != null && graphRef) {
|
||||
const link = graphRef.getLink(input.link)
|
||||
if (link) {
|
||||
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
|
||||
if (link && originNode) {
|
||||
originNodeId = String(link.origin_id)
|
||||
const originNode = graphRef.getNodeById(link.origin_id)
|
||||
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
|
||||
originOutputName = originNode.outputs?.[link.origin_slot]?.name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
|
||||
|
||||
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
|
||||
'KSampler',
|
||||
'KSamplerAdvanced',
|
||||
'PreviewImage',
|
||||
'SaveImage',
|
||||
'GLSLShader'
|
||||
|
||||
@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
|
||||
|
||||
const buildJsonataContext = (
|
||||
node: LGraphNode,
|
||||
rule: JsonataPricingRule
|
||||
rule: JsonataPricingRule,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): JsonataEvalContext => {
|
||||
const widgets: Record<string, NormalizedWidgetValue> = {}
|
||||
for (const dep of rule.depends_on.widgets) {
|
||||
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
|
||||
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
|
||||
const raw = widgetOverrides?.has(dep.name)
|
||||
? widgetOverrides.get(dep.name)
|
||||
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
|
||||
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
|
||||
}
|
||||
|
||||
const inputs: Record<string, { connected: boolean }> = {}
|
||||
@@ -552,7 +555,10 @@ export const useNodePricing = () => {
|
||||
* - schedules async evaluation when needed
|
||||
* - remains non-fatal on errors (returns safe fallback '')
|
||||
*/
|
||||
const getNodeDisplayPrice = (node: LGraphNode): string => {
|
||||
const getNodeDisplayPrice = (
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): string => {
|
||||
// Make this function reactive: when async evaluation completes, we bump pricingTick,
|
||||
// which causes this getter to recompute in Vue render/computed contexts.
|
||||
void pricingTick.value
|
||||
@@ -565,7 +571,7 @@ export const useNodePricing = () => {
|
||||
if (rule.engine !== 'jsonata') return ''
|
||||
if (!rule._compiled) return ''
|
||||
|
||||
const ctx = buildJsonataContext(node, rule)
|
||||
const ctx = buildJsonataContext(node, rule, widgetOverrides)
|
||||
const sig = buildSignature(ctx, rule)
|
||||
|
||||
const cached = cache.get(node)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
@@ -9,14 +11,25 @@ componentIconSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
|
||||
|
||||
export const usePriceBadge = () => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
function updateSubgraphCredits(node: LGraphNode) {
|
||||
if (!node.isSubgraphNode()) return
|
||||
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
|
||||
const newBadges = collectCreditsBadges(node.subgraph)
|
||||
if (newBadges.length > 1) {
|
||||
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
|
||||
} else {
|
||||
node.badges.push(...newBadges)
|
||||
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
|
||||
if (innerCreditsBadges.length > 1) {
|
||||
node.badges.push(
|
||||
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
|
||||
)
|
||||
} else if (innerCreditsBadges.length === 1) {
|
||||
const innerApiNodes = collectInnerApiNodes(node.subgraph)
|
||||
// When a single inner api node is the price source, swap its static
|
||||
// getter for a wrapper-aware one that resolves promoted widget values.
|
||||
if (innerApiNodes.length === 1) {
|
||||
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
|
||||
} else {
|
||||
node.badges.push(...innerCreditsBadges)
|
||||
}
|
||||
}
|
||||
const graph = node.graph
|
||||
if (!graph) return
|
||||
@@ -28,13 +41,14 @@ export const usePriceBadge = () => {
|
||||
newValue: node.badges
|
||||
})
|
||||
}
|
||||
|
||||
function collectCreditsBadges(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): (LGraphBadge | (() => LGraphBadge))[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const badges = []
|
||||
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
|
||||
for (const node of graph.nodes) {
|
||||
badges.push(
|
||||
...(node.isSubgraphNode()
|
||||
@@ -45,6 +59,51 @@ export const usePriceBadge = () => {
|
||||
return badges
|
||||
}
|
||||
|
||||
function collectInnerApiNodes(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): LGraphNode[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const apiNodes: LGraphNode[] = []
|
||||
for (const node of graph.nodes) {
|
||||
if (node.isSubgraphNode()) {
|
||||
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
|
||||
} else if (node.constructor?.nodeData?.api_node) {
|
||||
apiNodes.push(node)
|
||||
}
|
||||
}
|
||||
return apiNodes
|
||||
}
|
||||
|
||||
function buildWrapperAwarePriceBadge(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): () => LGraphBadge {
|
||||
return () =>
|
||||
getCreditsBadge(
|
||||
nodePricing.getNodeDisplayPrice(
|
||||
innerNode,
|
||||
collectPromotedOverrides(wrapper, innerNode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPromotedOverrides(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
const innerId = String(innerNode.id)
|
||||
for (const w of wrapper.widgets ?? []) {
|
||||
if (!isPromotedWidgetView(w)) continue
|
||||
if (w.sourceNodeId !== innerId) continue
|
||||
overrides.set(w.sourceWidgetName, w.value)
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
|
||||
@@ -9,35 +9,39 @@ 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 { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
|
||||
import { usePromotedPreviews } from './usePromotedPreviews'
|
||||
|
||||
type MockNodeOutputStore = Pick<
|
||||
ReturnType<typeof useNodeOutputStore>,
|
||||
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
||||
| 'nodeOutputs'
|
||||
| 'nodePreviewImages'
|
||||
| 'getNodeImageUrls'
|
||||
| 'getNodeImageUrlsByExecutionId'
|
||||
| 'getNodeOutputByExecutionId'
|
||||
| 'getNodePreviewImagesByExecutionId'
|
||||
>
|
||||
|
||||
const getNodeImageUrls = vi.hoisted(() =>
|
||||
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
||||
)
|
||||
const useNodeOutputStoreMock = vi.hoisted(() =>
|
||||
vi.fn<() => MockNodeOutputStore>()
|
||||
)
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
return {
|
||||
useNodeOutputStore: useNodeOutputStoreMock
|
||||
}
|
||||
})
|
||||
|
||||
function createMockNodeOutputStore(): MockNodeOutputStore {
|
||||
return {
|
||||
const store: MockNodeOutputStore = {
|
||||
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
||||
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
||||
getNodeImageUrls
|
||||
getNodeImageUrls: vi.fn(),
|
||||
getNodeImageUrlsByExecutionId: vi.fn(),
|
||||
getNodeOutputByExecutionId: vi.fn(),
|
||||
getNodePreviewImagesByExecutionId: vi.fn()
|
||||
}
|
||||
return { useNodeOutputStore: () => store }
|
||||
})
|
||||
|
||||
function clearMockNodeOutputStore() {
|
||||
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
|
||||
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
|
||||
for (const key of Object.keys(nodePreviewImages))
|
||||
delete nodePreviewImages[key]
|
||||
}
|
||||
|
||||
function createSetup() {
|
||||
@@ -83,16 +87,43 @@ function seedPreviewImages(
|
||||
}
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
let nodeOutputStore: MockNodeOutputStore
|
||||
function exposePreview(
|
||||
setup: ReturnType<typeof createSetup>,
|
||||
sourceNodeId: string,
|
||||
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
) {
|
||||
usePreviewExposureStore().addExposure(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
String(setup.subgraphNode.id),
|
||||
{ sourceNodeId, sourcePreviewName }
|
||||
)
|
||||
}
|
||||
|
||||
interface ArrangeOptions {
|
||||
id?: number
|
||||
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
function arrangePromotedPreview(options: ArrangeOptions = {}) {
|
||||
const {
|
||||
id = 10,
|
||||
previewMediaType,
|
||||
urls = ['/view?filename=output.png']
|
||||
} = options
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id, previewMediaType })
|
||||
exposePreview(setup, String(id))
|
||||
seedOutputs(setup.subgraph.id, [id])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
|
||||
return { setup, urls }
|
||||
}
|
||||
|
||||
describe(usePromotedPreviews, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
getNodeImageUrls.mockReset()
|
||||
|
||||
nodeOutputStore = createMockNodeOutputStore()
|
||||
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
||||
vi.resetAllMocks()
|
||||
clearMockNodeOutputStore()
|
||||
})
|
||||
|
||||
it('returns empty array for non-SubgraphNode', () => {
|
||||
@@ -109,70 +140,50 @@ 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([])
|
||||
})
|
||||
|
||||
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' }
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=output.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
const { setup, urls } = arrangePromotedPreview({
|
||||
previewMediaType: 'image'
|
||||
})
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
urls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
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' }
|
||||
)
|
||||
it.for([
|
||||
['video', '/view?filename=output.webm'],
|
||||
['audio', '/view?filename=output.mp3']
|
||||
] as const)(
|
||||
'returns %s type when interior node has %s previewMediaType',
|
||||
([mediaType, url]) => {
|
||||
const { setup } = arrangePromotedPreview({
|
||||
previewMediaType: mediaType,
|
||||
urls: [url]
|
||||
})
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe(mediaType)
|
||||
}
|
||||
)
|
||||
|
||||
it('defaults preview type to image when previewMediaType is unset', () => {
|
||||
const { setup, urls } = arrangePromotedPreview()
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('video')
|
||||
})
|
||||
|
||||
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' }
|
||||
)
|
||||
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value[0].type).toBe('audio')
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
expect.objectContaining({ type: 'image', urls })
|
||||
])
|
||||
})
|
||||
|
||||
it('returns separate entries for multiple promoted $$ widgets', () => {
|
||||
@@ -185,23 +196,17 @@ 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) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
})
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => {
|
||||
if (node === node10) return ['/view?a=1']
|
||||
if (node === node20) return ['/view?b=2']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(2)
|
||||
@@ -212,21 +217,17 @@ 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] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -236,23 +237,19 @@ 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([])
|
||||
|
||||
const blobUrl = 'blob:http://localhost/glsl-preview'
|
||||
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
||||
getNodeImageUrls.mockReturnValue([blobUrl])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
|
||||
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: '$$canvas-image-preview',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: [blobUrl]
|
||||
}
|
||||
@@ -262,11 +259,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,36 +267,132 @@ 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', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
|
||||
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_WIDGET
|
||||
}
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
setup.subgraphNode.rootGraph.id,
|
||||
setup.subgraphNode.id,
|
||||
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
|
||||
store.addExposure(
|
||||
outerSetup.subgraphNode.rootGraph.id,
|
||||
String(outerSetup.subgraphNode.id),
|
||||
{
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
)
|
||||
|
||||
const mockUrls = ['/view?filename=img.png']
|
||||
seedOutputs(setup.subgraph.id, [10])
|
||||
getNodeImageUrls.mockReturnValue(mockUrls)
|
||||
const mockUrls = ['/view?filename=leaf.png']
|
||||
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
|
||||
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
|
||||
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
|
||||
)
|
||||
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(promotedPreviews.value).toHaveLength(1)
|
||||
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
||||
const { promotedPreviews } = usePromotedPreviews(
|
||||
() => outerSetup.subgraphNode
|
||||
)
|
||||
expect(promotedPreviews.value).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: mockUrls
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
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_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
|
||||
sourceNodeId: String(innerHost.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
|
||||
sourceNodeId: String(leafNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
const outputStore = useNodeOutputStore()
|
||||
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
|
||||
(executionId) => {
|
||||
if (executionId === firstLeafExecutionId) return ['blob:first']
|
||||
if (executionId === secondLeafExecutionId) return ['blob:second']
|
||||
return undefined
|
||||
}
|
||||
)
|
||||
vi.mocked(outputStore.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_WIDGET,
|
||||
type: 'image',
|
||||
urls: ['blob:first']
|
||||
}
|
||||
]
|
||||
)
|
||||
expect(
|
||||
usePromotedPreviews(() => secondHost).promotedPreviews.value
|
||||
).toEqual([
|
||||
{
|
||||
sourceNodeId: '10',
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
type: 'image',
|
||||
urls: ['blob:second']
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,9 @@ import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
@@ -14,65 +15,126 @@ interface PromotedPreview {
|
||||
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.
|
||||
*/
|
||||
const PREVIEW_TYPES_BY_MEDIA = {
|
||||
video: 'video',
|
||||
audio: 'audio'
|
||||
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
|
||||
|
||||
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
|
||||
const media = node.previewMediaType
|
||||
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
|
||||
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
|
||||
}
|
||||
return 'image'
|
||||
}
|
||||
|
||||
export function usePromotedPreviews(
|
||||
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const promotionStore = usePromotionStore()
|
||||
const previewExposureStore = usePreviewExposureStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafExecutionId: string,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
const locatorId = createNodeLocatorId(
|
||||
leafHost.subgraph.id,
|
||||
leafSourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
const reactiveExecutionOutputs =
|
||||
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
|
||||
const reactiveExecutionPreviews =
|
||||
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
|
||||
const hasAnySource =
|
||||
reactiveOutputs?.images?.length ||
|
||||
reactivePreviews?.length ||
|
||||
reactiveExecutionOutputs?.images?.length ||
|
||||
reactiveExecutionPreviews?.length
|
||||
if (!hasAnySource) return undefined
|
||||
return (
|
||||
nodeOutputStore.getNodeImageUrlsByExecutionId(
|
||||
leafExecutionId,
|
||||
interiorNode
|
||||
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
)
|
||||
}
|
||||
|
||||
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 exposures = previewExposureStore.getExposures(
|
||||
rootGraphId,
|
||||
hostLocator
|
||||
)
|
||||
if (!pseudoEntries.length) return []
|
||||
if (!exposures.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)
|
||||
if (!interiorNode) continue
|
||||
function resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
currentHostLocator: string,
|
||||
sourceNodeId: string
|
||||
) {
|
||||
const currentHost = hostNodesByLocator.get(currentHostLocator)
|
||||
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
|
||||
if (!(sourceNode instanceof SubgraphNode)) return undefined
|
||||
|
||||
// Read from both reactive refs to establish Vue dependency
|
||||
// tracking. getNodeImageUrls reads from non-reactive
|
||||
// app.nodeOutputs / app.nodePreviewImages, so without this
|
||||
// access the computed would never re-evaluate.
|
||||
const locatorId = createNodeLocatorId(
|
||||
node.subgraph.id,
|
||||
entry.sourceNodeId
|
||||
)
|
||||
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
|
||||
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
|
||||
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
|
||||
continue
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
|
||||
if (!urls?.length) continue
|
||||
|
||||
const type =
|
||||
interiorNode.previewMediaType === 'video'
|
||||
? 'video'
|
||||
: interiorNode.previewMediaType === 'audio'
|
||||
? 'audio'
|
||||
: 'image'
|
||||
|
||||
previews.push({
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourceWidgetName: entry.sourceWidgetName,
|
||||
type,
|
||||
urls
|
||||
})
|
||||
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
|
||||
const definitionLocator = String(sourceNode.id)
|
||||
const hasPathExposures =
|
||||
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
|
||||
const nestedHostLocator = hasPathExposures
|
||||
? pathLocator
|
||||
: definitionLocator
|
||||
hostNodesByLocator.set(nestedHostLocator, sourceNode)
|
||||
return { rootGraphId, hostNodeLocator: nestedHostLocator }
|
||||
}
|
||||
|
||||
return previews
|
||||
return exposures.flatMap((exposure): PromotedPreview[] => {
|
||||
const resolved = previewExposureStore.resolveChain(
|
||||
rootGraphId,
|
||||
hostLocator,
|
||||
exposure.name,
|
||||
resolveNestedHost
|
||||
)
|
||||
const leaf = resolved?.leaf ?? {
|
||||
sourceNodeId: exposure.sourceNodeId,
|
||||
sourcePreviewName: exposure.sourcePreviewName
|
||||
}
|
||||
const leafHostLocator =
|
||||
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
|
||||
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
|
||||
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
|
||||
if (!interiorNode) return []
|
||||
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
`${leafHostLocator}:${leaf.sourceNodeId}`,
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
return [
|
||||
{
|
||||
sourceNodeId: leaf.sourceNodeId,
|
||||
sourceWidgetName: leaf.sourcePreviewName,
|
||||
type: getPreviewMediaType(interiorNode),
|
||||
urls
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return { promotedPreviews }
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestRootGraph,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
function createHostWithInnerWidget(widgetName: string) {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const input = innerNode.addInput('value', 'number')
|
||||
innerNode.addWidget('number', widgetName, 0, () => {})
|
||||
input.widget = { name: widgetName }
|
||||
innerSubgraph.add(innerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
return { rootGraph, innerSubgraph, innerNode, hostNode }
|
||||
}
|
||||
|
||||
describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
it('returns entry unchanged when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns entry unchanged with disambiguator when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed',
|
||||
String(innerNode.id)
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: String(innerNode.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a single legacy prefix from widget name', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
rootGraph,
|
||||
inputs: [{ name: 'seed', type: 'number' }]
|
||||
})
|
||||
|
||||
const samplerNode = new LGraphNode('Sampler')
|
||||
const samplerInput = samplerNode.addInput('seed', 'number')
|
||||
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
|
||||
samplerInput.widget = { name: 'noise_seed' }
|
||||
innerSubgraph.add(samplerNode)
|
||||
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const nestedNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph
|
||||
})
|
||||
outerSubgraph.add(nestedNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
|
||||
const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed`
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(nestedNode.id),
|
||||
prefixedName
|
||||
)
|
||||
|
||||
expect(result.sourceWidgetName).toBe('noise_seed')
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('returns original entry when prefix cannot be resolved', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'999: nonexistent_widget'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: '999: nonexistent_widget'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { PromotedWidgetSource } 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
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
widgetName,
|
||||
disambiguator
|
||||
).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 })
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLegacyPrefixedEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetPatch | undefined {
|
||||
let remaining = sourceWidgetName
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLegacyProxyWidgetEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): PromotedWidgetSource {
|
||||
if (
|
||||
canResolve(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
const patch = resolveLegacyPrefixedEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
895
src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts
Normal file
895
src/core/graph/subgraph/migration/proxyWidgetMigration.test.ts
Normal file
@@ -0,0 +1,895 @@
|
||||
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 type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
flushProxyWidgetMigration,
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
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()
|
||||
LGraph.proxyWidgetMigrationFlush = undefined
|
||||
})
|
||||
|
||||
function buildHost(): SubgraphNode {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.graph!.add(hostNode)
|
||||
return hostNode
|
||||
}
|
||||
|
||||
function addInnerNode(
|
||||
host: SubgraphNode,
|
||||
type: string,
|
||||
build: (node: LGraphNode) => void = () => {}
|
||||
): LGraphNode {
|
||||
const node = new LGraphNode(type)
|
||||
build(node)
|
||||
host.subgraph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function addPromotedHostInput(
|
||||
host: SubgraphNode,
|
||||
args: {
|
||||
inputName: string
|
||||
promotedName: string
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
initialValue?: TWidgetValue
|
||||
}
|
||||
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
|
||||
let widgetValue: TWidgetValue = args.initialValue ?? 0
|
||||
const slot = host.addInput(args.inputName, '*')
|
||||
slot._widget = fromPartial<PromotedWidgetView>({
|
||||
node: host,
|
||||
name: args.promotedName,
|
||||
sourceNodeId: args.sourceNodeId,
|
||||
sourceWidgetName: args.sourceWidgetName,
|
||||
get value() {
|
||||
return widgetValue
|
||||
},
|
||||
set value(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
},
|
||||
hydrateHostValue(v: TWidgetValue) {
|
||||
widgetValue = v
|
||||
}
|
||||
})
|
||||
return {
|
||||
setValue: (v) => {
|
||||
widgetValue = v
|
||||
},
|
||||
getValue: () => widgetValue
|
||||
}
|
||||
}
|
||||
|
||||
function addPrimitiveWithTargets(
|
||||
host: SubgraphNode,
|
||||
args: {
|
||||
primitiveType?: string
|
||||
primitiveValue?: number
|
||||
targetCount: number
|
||||
outputType?: string
|
||||
targetSlotType?: string
|
||||
}
|
||||
): { primitive: LGraphNode; targets: LGraphNode[] } {
|
||||
const outputType = args.outputType ?? 'INT'
|
||||
const targetSlotType = args.targetSlotType ?? outputType
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', outputType)
|
||||
primitive.addWidget('number', 'value', args.primitiveValue ?? 42, () => {})
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
const targets: LGraphNode[] = []
|
||||
for (let i = 0; i < args.targetCount; i++) {
|
||||
const target = new LGraphNode(`Target${i}`)
|
||||
const slot = target.addInput('value', targetSlotType)
|
||||
slot.widget = { name: 'value' }
|
||||
target.addWidget('number', 'value', 0, () => {})
|
||||
host.subgraph.add(target)
|
||||
primitive.connect(0, target, 0)
|
||||
targets.push(target)
|
||||
}
|
||||
return { primitive, targets }
|
||||
}
|
||||
|
||||
describe('flushProxyWidgetMigration', () => {
|
||||
describe('no-op cases', () => {
|
||||
it('returns an empty result when no proxyWidgets are present', () => {
|
||||
const host = buildHost()
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tolerates a malformed proxyWidgets payload and returns empty', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = '{not json}'
|
||||
|
||||
expect(() => flushProxyWidgetMigration({ hostNode: host })).not.toThrow()
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('value-widget repair', () => {
|
||||
it('alreadyLinked: applies host value to the matching promoted widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 0
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(99)
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
host.graph!.add(host)
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
const innerWidget = n.addWidget('number', 'seed', 0, () => {})
|
||||
slot.widget = { name: innerWidget.name }
|
||||
})
|
||||
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(host.widgets[0].value).toBe(99)
|
||||
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
|
||||
expect(innerWidget.value).toBe(0)
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const handle = addPromotedHostInput(host, {
|
||||
inputName: 'seed_link',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 7
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
const sparse: unknown[] = []
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(handle.getValue()).toBe(7)
|
||||
})
|
||||
|
||||
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
const a = addPromotedHostInput(host, {
|
||||
inputName: 'first_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 1
|
||||
})
|
||||
const b = addPromotedHostInput(host, {
|
||||
inputName: 'second_seed',
|
||||
promotedName: 'seed',
|
||||
sourceNodeId: String(inner.id),
|
||||
sourceWidgetName: 'seed',
|
||||
initialValue: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(a.getValue()).toBe(1)
|
||||
expect(b.getValue()).toBe(2)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
|
||||
const slot1 = n.addInput('text', 'STRING')
|
||||
slot1.widget = { name: 'text' }
|
||||
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
|
||||
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
|
||||
|
||||
const slot2 = n.addInput('text_1', 'STRING')
|
||||
slot2.widget = { name: 'text_1' }
|
||||
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
|
||||
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const created = host.subgraph.inputs.at(-1)
|
||||
expect(created?._widget).toBeDefined()
|
||||
const linkedSlot = inner.inputs.find(
|
||||
(slot) => slot.link === created?.linkIds[0]
|
||||
)
|
||||
expect(linkedSlot?.name).toBe('text_1')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'seed'],
|
||||
reason: 'missingSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive fan-out repair', () => {
|
||||
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 3
|
||||
})
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
|
||||
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('coalesces duplicate cohort entries pointing at the same primitive', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 2
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
for (const target of targets) {
|
||||
const slot = target.inputs[0]
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('host value wins over primitive widget value', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 2,
|
||||
primitiveValue: 11
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [123]
|
||||
})
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(123)
|
||||
})
|
||||
|
||||
it('seeds value from the primitive widget when no host value is supplied', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1,
|
||||
primitiveValue: 11
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const hostInput = host.inputs.at(-1)
|
||||
expect(hostInput?._widget?.value).toBe(11)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'unlinkedSourceWidget'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines all cohort entries when a target slot type is incompatible', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
targets[0].inputs[0].type = 'STRING'
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
|
||||
const danglingLinkId = 999_999
|
||||
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
|
||||
primitive.outputs[0].links = [
|
||||
...(primitive.outputs[0].links ?? []),
|
||||
danglingLinkId
|
||||
]
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps independent values across two hosts of the same subgraph', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostA = createTestSubgraphNode(subgraph)
|
||||
const hostB = createTestSubgraphNode(subgraph)
|
||||
hostA.graph!.add(hostA)
|
||||
hostB.graph!.add(hostB)
|
||||
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
primitive.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(primitive)
|
||||
|
||||
const target = new LGraphNode('Target')
|
||||
const slot = target.addInput('value', 'INT')
|
||||
slot.widget = { name: 'value' }
|
||||
target.addWidget('number', 'value', 0, () => {})
|
||||
subgraph.add(target)
|
||||
primitive.connect(0, target, 0)
|
||||
|
||||
hostA.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
hostB.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: hostA,
|
||||
hostWidgetValues: [11]
|
||||
})
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: hostB,
|
||||
hostWidgetValues: [22]
|
||||
})
|
||||
|
||||
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
|
||||
const widgetA = hostA.inputs.at(-1)?._widget
|
||||
const widgetB = hostB.inputs.at(-1)?._widget
|
||||
expect(widgetA?.value).toBe(11)
|
||||
expect(widgetB?.value).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview exposure migration', () => {
|
||||
it('adds an exposure for a $$-prefixed preview source', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
expect(exposures).toHaveLength(1)
|
||||
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
|
||||
expect(exposures[0].sourceNodeId).toBe(String(inner.id))
|
||||
})
|
||||
|
||||
it('classifies type:preview serialize:false widgets as preview exposure', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const widget = n.addWidget('text', 'videopreview', '', () => {})
|
||||
widget.type = 'preview'
|
||||
widget.serialize = false
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'videopreview']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
expect(exposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: String(inner.id),
|
||||
sourcePreviewName: 'videopreview'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('produces a unique name on collision via nextUniqueName', () => {
|
||||
const host = buildHost()
|
||||
const innerA = addInnerNode(host, 'InnerA', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
const innerB = addInnerNode(host, 'InnerB', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const locator = String(host.id)
|
||||
store.addExposure(host.rootGraph.id, locator, {
|
||||
sourceNodeId: String(innerA.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(innerB.id), '$$canvas-image-preview']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const exposures = store.getExposures(host.rootGraph.id, locator)
|
||||
expect(exposures).toHaveLength(2)
|
||||
const newExposure = exposures.find(
|
||||
(e) => e.sourceNodeId === String(innerB.id)
|
||||
)
|
||||
expect(newExposure?.name).toBe('$$canvas-image-preview_1')
|
||||
})
|
||||
|
||||
it('reuses an existing exposure for the same source preview', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
|
||||
const store = usePreviewExposureStore()
|
||||
const locator = String(host.id)
|
||||
store.addExposure(host.rootGraph.id, locator, {
|
||||
sourceNodeId: String(inner.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('quarantine accumulation', () => {
|
||||
it('quarantines entries whose source node has disappeared', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
{
|
||||
originalEntry: ['9999', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines entries whose source widget is missing on the source node', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner')
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'nonexistent']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(inner.id), 'nonexistent'],
|
||||
reason: 'missingSourceWidget'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the host value on the quarantine row when one was supplied', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [42]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: ['9999', 'seed'],
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 42
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips appended entries via the public read helper', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
const first = readHostQuarantine(host)
|
||||
expect(first).toHaveLength(1)
|
||||
|
||||
host.properties.proxyWidgets = [['9999', 'seed', 'inner-leaf']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const after = readHostQuarantine(host)
|
||||
expect(after).toHaveLength(2)
|
||||
expect(after.map((e) => e.originalEntry)).toEqual([
|
||||
['9999', 'seed'],
|
||||
['9999', 'seed', 'inner-leaf']
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates entries with identical originalEntry tuples on re-flush', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
const firstQuarantine = readHostQuarantine(host)
|
||||
expect(firstQuarantine).toHaveLength(1)
|
||||
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
it('clears properties.proxyWidgets after a successful flush', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-running flush over a fully migrated host produces no further mutations', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const exposuresAfterFirst = usePreviewExposureStore()
|
||||
.getExposures(host.rootGraph.id, String(host.id))
|
||||
.map((e) => ({ ...e }))
|
||||
expect(exposuresAfterFirst).toHaveLength(1)
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
).toEqual(exposuresAfterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed cohort', () => {
|
||||
it('migrates a mixed value+preview cohort in one flush, preserving entry order', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
|
||||
const subgraphInputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), 'seed'],
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [99]
|
||||
})
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
|
||||
expect(host.subgraph.inputs.find((i) => i.name === 'seed')).toBeDefined()
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
host.rootGraph.id,
|
||||
String(host.id)
|
||||
)
|
||||
expect(exposures).toHaveLength(1)
|
||||
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
|
||||
})
|
||||
|
||||
it('preserves sparse holes when supplied widgets_values is missing an index', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slotA = n.addInput('a', 'INT')
|
||||
slotA.widget = { name: 'a' }
|
||||
n.addWidget('number', 'a', 0, () => {})
|
||||
const slotB = n.addInput('b', 'INT')
|
||||
slotB.widget = { name: 'b' }
|
||||
n.addWidget('number', 'b', 0, () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), 'a'],
|
||||
[String(inner.id), 'b']
|
||||
]
|
||||
const sparse: unknown[] = []
|
||||
sparse[1] = 'second-value'
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: sparse
|
||||
})
|
||||
|
||||
expect(host.subgraph.inputs.find((i) => i.name === 'a')).toBeDefined()
|
||||
expect(host.subgraph.inputs.find((i) => i.name === 'b')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration with LGraph.configure', () => {
|
||||
it('runs through LGraph.configure when the migration hook is wired', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
n.addWidget('text', '$$canvas-image-preview', '', () => {})
|
||||
})
|
||||
host.properties.proxyWidgets = [
|
||||
[String(inner.id), '$$canvas-image-preview']
|
||||
]
|
||||
|
||||
const serialized = host.rootGraph.serialize()
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
hostWidgetValues: nodeData?.widgets_values
|
||||
})
|
||||
|
||||
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(inner.id),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
function createHostWithInnerWidget(widgetName: string) {
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('InnerNode')
|
||||
const input = innerNode.addInput('value', 'number')
|
||||
innerNode.addWidget('number', widgetName, 0, () => {})
|
||||
input.widget = { name: widgetName }
|
||||
subgraph.add(innerNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
hostNode.graph!.add(hostNode)
|
||||
|
||||
return { innerNode, hostNode }
|
||||
}
|
||||
|
||||
it('returns entry unchanged when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns entry unchanged with disambiguator when it already resolves', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'seed',
|
||||
String(innerNode.id)
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: String(innerNode.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a single legacy prefix from widget name', () => {
|
||||
const innerSubgraph = createTestSubgraph()
|
||||
const samplerNode = new LGraphNode('Sampler')
|
||||
const samplerInput = samplerNode.addInput('seed', 'number')
|
||||
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
|
||||
samplerInput.widget = { name: 'noise_seed' }
|
||||
innerSubgraph.add(samplerNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(innerSubgraph)
|
||||
hostNode.graph!.add(hostNode)
|
||||
|
||||
const prefixedName = `${samplerNode.id}: noise_seed`
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(samplerNode.id),
|
||||
prefixedName
|
||||
)
|
||||
|
||||
expect(result.sourceWidgetName).toBe('noise_seed')
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'999: nonexistent_widget'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'nonexistent_widget',
|
||||
disambiguatingSourceNodeId: '999'
|
||||
})
|
||||
})
|
||||
})
|
||||
790
src/core/graph/subgraph/migration/proxyWidgetMigration.ts
Normal file
790
src/core/graph/subgraph/migration/proxyWidgetMigration.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import {
|
||||
findHostInputForPromotion,
|
||||
getPromotableWidgets,
|
||||
isPreviewPseudoWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import type {
|
||||
ProxyWidgetErrorQuarantineEntry,
|
||||
ProxyWidgetQuarantineReason
|
||||
} from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
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'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
interface LegacyProxyEntrySource extends PromotedWidgetSource {
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
|
||||
|
||||
interface StrippedPrefix {
|
||||
sourceWidgetName: string
|
||||
deepestPrefixId?: string
|
||||
}
|
||||
|
||||
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 { sourceWidgetName: remaining, deepestPrefixId }
|
||||
deepestPrefixId = match[1]
|
||||
remaining = match[2]
|
||||
}
|
||||
}
|
||||
|
||||
function canResolveLegacyProxy(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
widgetName: string
|
||||
): boolean {
|
||||
return (
|
||||
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
|
||||
'resolved'
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeLegacyProxyWidgetEntry(
|
||||
hostNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): LegacyProxyEntrySource {
|
||||
if (canResolveLegacyProxy(hostNode, sourceNodeId, sourceWidgetName)) {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
const stripped = stripLegacyPrefixes(sourceWidgetName)
|
||||
const patchDisambiguatingSourceNodeId =
|
||||
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
|
||||
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: stripped.sourceWidgetName,
|
||||
...(patchDisambiguatingSourceNodeId && {
|
||||
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceWidget(
|
||||
sourceNode: LGraphNode,
|
||||
sourceWidgetName: string,
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IBaseWidget | undefined {
|
||||
const widgets = sourceNode.widgets
|
||||
if (widgets && disambiguatingSourceNodeId !== undefined) {
|
||||
const byDisambiguator = widgets.find(
|
||||
(w) =>
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === disambiguatingSourceNodeId &&
|
||||
w.sourceWidgetName === sourceWidgetName
|
||||
)
|
||||
if (byDisambiguator) return byDisambiguator
|
||||
// Disambiguator missed: fall back only to non-promoted same-name widgets.
|
||||
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
|
||||
const byName = widgets.find(
|
||||
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
|
||||
)
|
||||
if (byName) return byName
|
||||
}
|
||||
|
||||
return (
|
||||
widgets?.find((w) => w.name === sourceWidgetName) ??
|
||||
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
|
||||
)
|
||||
}
|
||||
|
||||
interface FlushArgs {
|
||||
hostNode: SubgraphNode
|
||||
hostWidgetValues?: readonly unknown[]
|
||||
}
|
||||
|
||||
interface PrimitiveBypassTargetRef {
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
type Plan =
|
||||
| { 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 }
|
||||
|
||||
interface PendingEntry {
|
||||
normalized: LegacyProxyEntrySource
|
||||
hostValue: TWidgetValue | undefined
|
||||
isHole: boolean
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
|
||||
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
|
||||
const QUARANTINE_VERSION = 1
|
||||
const PROXY_BYPASS_MARKER_PROPERTY = 'proxyBypassedToSubgraphInput'
|
||||
|
||||
export function flushProxyWidgetMigration(args: FlushArgs): void {
|
||||
const { hostNode, hostWidgetValues } = args
|
||||
|
||||
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
|
||||
if (tuples.length === 0) return
|
||||
|
||||
const cohort: LegacyProxyEntrySource[] = tuples.map(
|
||||
([sourceNodeId, sourceWidgetName, disambiguator]) =>
|
||||
normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
disambiguator
|
||||
)
|
||||
)
|
||||
|
||||
const pending: PendingEntry[] = cohort.map((normalized, index) => {
|
||||
const { value, isHole } = pickHostValue(hostWidgetValues, index)
|
||||
return {
|
||||
normalized,
|
||||
hostValue: value,
|
||||
isHole,
|
||||
plan: classify(hostNode, normalized, cohort)
|
||||
}
|
||||
})
|
||||
|
||||
const previewStore = usePreviewExposureStore()
|
||||
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
|
||||
const primitiveCohorts = new Map<NodeId, PendingEntry[]>()
|
||||
|
||||
for (const entry of pending) {
|
||||
switch (entry.plan.kind) {
|
||||
case 'primitiveBypass': {
|
||||
const c = primitiveCohorts.get(entry.plan.primitiveNodeId) ?? []
|
||||
c.push(entry)
|
||||
primitiveCohorts.set(entry.plan.primitiveNodeId, c)
|
||||
break
|
||||
}
|
||||
case 'alreadyLinked': {
|
||||
const r = repairAlreadyLinked(
|
||||
hostNode,
|
||||
entry,
|
||||
entry.plan.subgraphInputName
|
||||
)
|
||||
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
|
||||
break
|
||||
}
|
||||
case 'createSubgraphInput': {
|
||||
const r = repairCreateSubgraphInput(
|
||||
hostNode,
|
||||
entry,
|
||||
entry.plan.sourceWidgetName
|
||||
)
|
||||
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
|
||||
break
|
||||
}
|
||||
case 'previewExposure': {
|
||||
const r = migratePreview(hostNode, entry, previewStore, entry.plan)
|
||||
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
|
||||
break
|
||||
}
|
||||
case 'quarantine':
|
||||
quarantineToAppend.push(quarantineFor(entry, entry.plan.reason))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of primitiveCohorts.values()) {
|
||||
const r = repairPrimitive(hostNode, c)
|
||||
if (!r.ok)
|
||||
for (const e of c) quarantineToAppend.push(quarantineFor(e, r.reason))
|
||||
}
|
||||
|
||||
if (quarantineToAppend.length > 0) {
|
||||
appendQuarantine(hostNode, quarantineToAppend)
|
||||
}
|
||||
|
||||
delete hostNode.properties.proxyWidgets
|
||||
}
|
||||
|
||||
function pickHostValue(
|
||||
hostWidgetValues: readonly unknown[] | undefined,
|
||||
index: number
|
||||
): { value: TWidgetValue | undefined; isHole: boolean } {
|
||||
if (
|
||||
hostWidgetValues === undefined ||
|
||||
index < 0 ||
|
||||
index >= hostWidgetValues.length ||
|
||||
!Object.hasOwn(hostWidgetValues, index)
|
||||
) {
|
||||
return { value: undefined, isHole: true }
|
||||
}
|
||||
const raw = hostWidgetValues[index]
|
||||
if (!isWidgetValue(raw)) return { value: undefined, isHole: true }
|
||||
return { value: raw, isHole: false }
|
||||
}
|
||||
|
||||
function collectTargetsStrict(
|
||||
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 collectTargetsSkippingDangling(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
|
||||
return linkIds.flatMap((linkId) => {
|
||||
const link = subgraph.links.get(linkId)
|
||||
return link
|
||||
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
function cohortDuplicatesPrimitive(
|
||||
cohort: readonly LegacyProxyEntrySource[],
|
||||
primitiveNodeId: string
|
||||
): boolean {
|
||||
return (
|
||||
cohort.filter((entry) => entry.sourceNodeId === primitiveNodeId).length >= 2
|
||||
)
|
||||
}
|
||||
|
||||
function classify(
|
||||
hostNode: SubgraphNode,
|
||||
normalized: LegacyProxyEntrySource,
|
||||
cohort: readonly LegacyProxyEntrySource[]
|
||||
): Plan {
|
||||
const linkedInput = findHostInputForPromotion(
|
||||
hostNode,
|
||||
normalized.sourceNodeId,
|
||||
normalized.sourceWidgetName
|
||||
)
|
||||
if (linkedInput) {
|
||||
const ambiguous =
|
||||
hostNode.inputs.filter((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
!!w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === normalized.sourceNodeId &&
|
||||
w.sourceWidgetName === normalized.sourceWidgetName
|
||||
)
|
||||
}).length > 1
|
||||
if (ambiguous) {
|
||||
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
|
||||
}
|
||||
|
||||
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
|
||||
if (!sourceNode) {
|
||||
return { kind: 'quarantine', reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
|
||||
const bypassedTo = sourceNode.properties?.[PROXY_BYPASS_MARKER_PROPERTY]
|
||||
if (typeof bypassedTo === 'string') {
|
||||
const existingInput = hostNode.inputs.find(
|
||||
(input) => input.name === bypassedTo
|
||||
)
|
||||
if (existingInput) {
|
||||
return { kind: 'alreadyLinked', subgraphInputName: existingInput.name }
|
||||
}
|
||||
}
|
||||
|
||||
const targets = collectTargetsSkippingDangling(hostNode, sourceNode)
|
||||
const cohortDuplicated = cohortDuplicatesPrimitive(
|
||||
cohort,
|
||||
normalized.sourceNodeId
|
||||
)
|
||||
if (targets.length >= 1 || cohortDuplicated) {
|
||||
return {
|
||||
kind: 'primitiveBypass',
|
||||
primitiveNodeId: sourceNode.id,
|
||||
sourceWidgetName: normalized.sourceWidgetName,
|
||||
targets
|
||||
}
|
||||
}
|
||||
return { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
|
||||
}
|
||||
|
||||
const sourceWidget = resolveSourceWidget(
|
||||
sourceNode,
|
||||
normalized.sourceWidgetName,
|
||||
normalized.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { kind: 'quarantine', reason: 'missingSourceWidget' }
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.sourceWidgetName.startsWith('$$') ||
|
||||
isPreviewPseudoWidget(sourceWidget)
|
||||
) {
|
||||
return {
|
||||
kind: 'previewExposure',
|
||||
sourcePreviewName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'createSubgraphInput',
|
||||
sourceWidgetName: normalized.sourceWidgetName
|
||||
}
|
||||
}
|
||||
|
||||
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
|
||||
if (entry.isHole) return
|
||||
if (
|
||||
isPromotedWidgetView(widget) &&
|
||||
typeof widget.hydrateHostValue === 'function'
|
||||
) {
|
||||
widget.hydrateHostValue(entry.hostValue)
|
||||
return
|
||||
}
|
||||
console.error(
|
||||
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
|
||||
{ widgetName: widget.name, type: widget.type }
|
||||
)
|
||||
}
|
||||
|
||||
function addUniqueSubgraphInput(
|
||||
subgraph: Subgraph,
|
||||
baseName: string,
|
||||
type: string
|
||||
): SubgraphInput {
|
||||
const existingNames = subgraph.inputs.map((input) => input.name)
|
||||
const uniqueName = nextUniqueName(baseName, existingNames)
|
||||
return subgraph.addInput(uniqueName, type)
|
||||
}
|
||||
|
||||
type Outcome<TOk, TReason = ProxyWidgetQuarantineReason> =
|
||||
| ({ ok: true } & TOk)
|
||||
| { ok: false; reason: TReason }
|
||||
|
||||
type RepairValueResult = Outcome<{ subgraphInputName: string }>
|
||||
|
||||
function repairAlreadyLinked(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingEntry,
|
||||
subgraphInputName: string
|
||||
): RepairValueResult {
|
||||
// Resolve by name directly: source-id matching would miss for primitive
|
||||
// bypasses, where the view's `sourceNodeId` is the consumer, not the
|
||||
// primitive.
|
||||
const matches = hostNode.inputs.filter(
|
||||
(input) => input.name === subgraphInputName
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return { ok: false, reason: 'ambiguousSubgraphInput' }
|
||||
}
|
||||
const hostInput = matches[0]
|
||||
if (!hostInput._widget) {
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
applyHostValue(hostInput._widget, entry)
|
||||
return { ok: true, subgraphInputName: hostInput.name }
|
||||
}
|
||||
|
||||
function repairCreateSubgraphInput(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingEntry,
|
||||
sourceWidgetName: string
|
||||
): RepairValueResult {
|
||||
const subgraph = hostNode.subgraph
|
||||
const sourceNode: LGraphNode | null = subgraph.getNodeById(
|
||||
entry.normalized.sourceNodeId
|
||||
)
|
||||
if (!sourceNode) {
|
||||
return { ok: false, reason: 'missingSourceNode' }
|
||||
}
|
||||
|
||||
const sourceWidget = resolveSourceWidget(
|
||||
sourceNode,
|
||||
sourceWidgetName,
|
||||
entry.normalized.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { ok: false, reason: 'missingSourceWidget' }
|
||||
}
|
||||
|
||||
const slot: INodeInputSlot | undefined =
|
||||
sourceNode.getSlotFromWidget(sourceWidget)
|
||||
if (!slot) {
|
||||
console.warn(
|
||||
'[proxyWidgetMigration] source widget has no backing input slot; quarantining',
|
||||
{
|
||||
sourceNodeId: entry.normalized.sourceNodeId,
|
||||
sourceWidgetName
|
||||
}
|
||||
)
|
||||
return { ok: false, reason: 'missingSubgraphInput' }
|
||||
}
|
||||
|
||||
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
|
||||
const newSubgraphInput = addUniqueSubgraphInput(
|
||||
subgraph,
|
||||
sourceWidgetName,
|
||||
slotType
|
||||
)
|
||||
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)
|
||||
return { ok: true, subgraphInputName: newSubgraphInput.name }
|
||||
}
|
||||
|
||||
type RepairPrimitiveResult = Outcome<
|
||||
{ subgraphInputName: string; reconnectCount: number },
|
||||
'primitiveBypassFailed'
|
||||
>
|
||||
|
||||
const PRIMITIVE_FAILED: RepairPrimitiveResult = {
|
||||
ok: false,
|
||||
reason: 'primitiveBypassFailed'
|
||||
}
|
||||
|
||||
interface SnapshotLink extends PrimitiveBypassTargetRef {
|
||||
primitiveSlot: number
|
||||
}
|
||||
|
||||
interface CohortValidationOk {
|
||||
ok: true
|
||||
primitiveNodeId: NodeId
|
||||
sourceWidgetName: string
|
||||
uniqueEntries: readonly PendingEntry[]
|
||||
}
|
||||
|
||||
function failPrimitive(message: string, ctx?: unknown): RepairPrimitiveResult {
|
||||
console.warn(`[proxyWidgetMigration] ${message}`, ctx)
|
||||
return PRIMITIVE_FAILED
|
||||
}
|
||||
|
||||
function userRenamedTitle(primitiveNode: LGraphNode): string | undefined {
|
||||
const title = primitiveNode.title
|
||||
return title && title !== PRIMITIVE_NODE_TYPE ? title : undefined
|
||||
}
|
||||
|
||||
function validateCohort(
|
||||
cohort: readonly PendingEntry[]
|
||||
): CohortValidationOk | { ok: false } {
|
||||
const first = cohort[0]
|
||||
if (!first || first.plan.kind !== 'primitiveBypass') return { ok: false }
|
||||
const { primitiveNodeId, sourceWidgetName } = first.plan
|
||||
for (const entry of cohort) {
|
||||
if (
|
||||
entry.plan.kind !== 'primitiveBypass' ||
|
||||
entry.plan.primitiveNodeId !== primitiveNodeId ||
|
||||
entry.plan.sourceWidgetName !== sourceWidgetName
|
||||
) {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
const uniqueEntries: PendingEntry[] = []
|
||||
for (const entry of cohort) {
|
||||
if (!uniqueEntries.some((k) => isEqual(k.normalized, entry.normalized))) {
|
||||
uniqueEntries.push(entry)
|
||||
}
|
||||
}
|
||||
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
|
||||
}
|
||||
|
||||
function rollback(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode,
|
||||
newSubgraphInput: SubgraphInput | undefined,
|
||||
snapshot: readonly SnapshotLink[]
|
||||
): void {
|
||||
if (newSubgraphInput) {
|
||||
try {
|
||||
hostNode.subgraph.removeInput(newSubgraphInput)
|
||||
} catch (e) {
|
||||
console.warn('[proxyWidgetMigration] 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 repairPrimitive(
|
||||
hostNode: SubgraphNode,
|
||||
cohort: readonly PendingEntry[]
|
||||
): RepairPrimitiveResult {
|
||||
const validated = validateCohort(cohort)
|
||||
if (!validated.ok)
|
||||
return failPrimitive('cohort validation failed', { cohort })
|
||||
|
||||
const subgraph = hostNode.subgraph
|
||||
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
|
||||
if (!primitiveNode) return failPrimitive('primitive node missing', validated)
|
||||
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
|
||||
return failPrimitive('node is not a PrimitiveNode', primitiveNode.type)
|
||||
}
|
||||
|
||||
const targets = collectTargetsStrict(hostNode, primitiveNode)
|
||||
if (!targets?.length)
|
||||
return failPrimitive('no targets to reconnect', validated)
|
||||
|
||||
const primitiveOutput = primitiveNode.outputs?.[0]
|
||||
if (!primitiveOutput) return failPrimitive('primitive has no output')
|
||||
const primitiveOutputType = String(primitiveOutput.type ?? '*')
|
||||
|
||||
for (const target of targets) {
|
||||
const targetNode = subgraph.getNodeById(target.targetNodeId)
|
||||
if (!targetNode) return failPrimitive('target node missing', target)
|
||||
const targetSlot = targetNode.inputs?.[target.targetSlot]
|
||||
if (!targetSlot) return failPrimitive('target slot missing', target)
|
||||
const targetType = String(targetSlot.type ?? '*')
|
||||
if (
|
||||
targetType !== primitiveOutputType &&
|
||||
targetType !== '*' &&
|
||||
primitiveOutputType !== '*'
|
||||
) {
|
||||
return failPrimitive('target slot type incompatible', {
|
||||
target,
|
||||
targetType,
|
||||
primitiveOutputType
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const baseName = userRenamedTitle(primitiveNode) ?? validated.sourceWidgetName
|
||||
const snapshot: SnapshotLink[] = (primitiveOutput.links ?? [])
|
||||
.map((id) => subgraph.links.get(id))
|
||||
.filter((l): l is NonNullable<typeof l> => l !== undefined)
|
||||
.map((l) => ({
|
||||
primitiveSlot: l.origin_slot,
|
||||
targetNodeId: l.target_id,
|
||||
targetSlot: l.target_slot
|
||||
}))
|
||||
|
||||
let newSubgraphInput: SubgraphInput | undefined
|
||||
try {
|
||||
newSubgraphInput = addUniqueSubgraphInput(
|
||||
subgraph,
|
||||
baseName,
|
||||
primitiveOutputType
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 failPrimitive('mutation failed; rolled back', { error: e })
|
||||
}
|
||||
|
||||
// Apply through the host's input mirror (PromotedWidgetView), NOT
|
||||
// `newSubgraphInput._widget`: the interior is shared across hosts.
|
||||
const hostInput = hostNode.inputs.find(
|
||||
(input) => input.name === newSubgraphInput.name
|
||||
)
|
||||
const hostInputWidget = hostInput?._widget
|
||||
if (hostInputWidget) {
|
||||
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
|
||||
if (valueEntry) {
|
||||
applyHostValue(hostInputWidget, valueEntry)
|
||||
} else {
|
||||
const primitiveValue = primitiveNode.widgets?.find(
|
||||
(w) => w.name === validated.sourceWidgetName
|
||||
)?.value as TWidgetValue | undefined
|
||||
if (primitiveValue !== undefined) {
|
||||
applyHostValue(hostInputWidget, {
|
||||
...validated.uniqueEntries[0],
|
||||
hostValue: primitiveValue,
|
||||
isHole: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
primitiveNode.properties ??= {}
|
||||
primitiveNode.properties[PROXY_BYPASS_MARKER_PROPERTY] = newSubgraphInput.name
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
subgraphInputName: newSubgraphInput.name,
|
||||
reconnectCount: targets.length
|
||||
}
|
||||
}
|
||||
|
||||
type MigratePreviewResult = Outcome<
|
||||
{ previewName: string },
|
||||
'missingSourceNode' | 'missingSourceWidget'
|
||||
>
|
||||
|
||||
function migratePreview(
|
||||
hostNode: SubgraphNode,
|
||||
entry: PendingEntry,
|
||||
store: ReturnType<typeof usePreviewExposureStore>,
|
||||
plan: { kind: 'previewExposure'; sourcePreviewName: string }
|
||||
): MigratePreviewResult {
|
||||
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 }
|
||||
}
|
||||
|
||||
function quarantineFor(
|
||||
entry: PendingEntry,
|
||||
reason: ProxyWidgetQuarantineReason
|
||||
): ProxyWidgetErrorQuarantineEntry {
|
||||
const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } =
|
||||
entry.normalized
|
||||
const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId
|
||||
? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId]
|
||||
: [sourceNodeId, sourceWidgetName]
|
||||
return makeQuarantineEntry({
|
||||
originalEntry,
|
||||
reason,
|
||||
hostValue: entry.isHole ? undefined : entry.hostValue
|
||||
})
|
||||
}
|
||||
|
||||
export function appendQuarantine(
|
||||
hostNode: SubgraphNode,
|
||||
entries: readonly ProxyWidgetErrorQuarantineEntry[]
|
||||
): void {
|
||||
if (entries.length === 0) return
|
||||
const existing = parseProxyWidgetErrorQuarantine(
|
||||
hostNode.properties[QUARANTINE_PROPERTY]
|
||||
)
|
||||
const merged = [...existing]
|
||||
for (const candidate of entries) {
|
||||
if (
|
||||
!merged.some((e) => isEqual(e.originalEntry, candidate.originalEntry))
|
||||
) {
|
||||
merged.push(candidate)
|
||||
}
|
||||
}
|
||||
if (merged.length === 0) delete hostNode.properties[QUARANTINE_PROPERTY]
|
||||
else hostNode.properties[QUARANTINE_PROPERTY] = merged
|
||||
}
|
||||
|
||||
export function readHostQuarantine(
|
||||
hostNode: SubgraphNode
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
return parseProxyWidgetErrorQuarantine(
|
||||
hostNode.properties[QUARANTINE_PROPERTY]
|
||||
)
|
||||
}
|
||||
|
||||
export function makeQuarantineEntry(args: {
|
||||
originalEntry: SerializedProxyWidgetTuple
|
||||
reason: ProxyWidgetQuarantineReason
|
||||
hostValue?: TWidgetValue
|
||||
}): ProxyWidgetErrorQuarantineEntry {
|
||||
const entry: ProxyWidgetErrorQuarantineEntry = {
|
||||
originalEntry: args.originalEntry,
|
||||
reason: args.reason,
|
||||
attemptedAtVersion: QUARANTINE_VERSION
|
||||
}
|
||||
if (args.hostValue !== undefined) {
|
||||
entry.hostValue = args.hostValue
|
||||
}
|
||||
return entry
|
||||
}
|
||||
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')
|
||||
})
|
||||
})
|
||||
105
src/core/graph/subgraph/preview/previewExposureChain.ts
Normal file
105
src/core/graph/subgraph/preview/previewExposureChain.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
export interface ResolvedPreviewChainStep {
|
||||
rootGraphId: UUID
|
||||
hostNodeLocator: string
|
||||
exposure: PreviewExposure
|
||||
}
|
||||
|
||||
export interface ResolvedPreviewChain {
|
||||
steps: readonly ResolvedPreviewChainStep[]
|
||||
leaf: {
|
||||
rootGraphId: UUID
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PreviewExposureChainContext {
|
||||
getExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): readonly PreviewExposure[]
|
||||
resolveNestedHost(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
sourceNodeId: string
|
||||
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
|
||||
}
|
||||
|
||||
const MAX_CHAIN_DEPTH = 32
|
||||
|
||||
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
|
||||
|
||||
const chainFromLastStep = (): ResolvedPreviewChain | undefined => {
|
||||
if (steps.length === 0) return undefined
|
||||
const lastStep = steps[steps.length - 1]
|
||||
return {
|
||||
steps,
|
||||
leaf: {
|
||||
rootGraphId: lastStep.rootGraphId,
|
||||
sourceNodeId: lastStep.exposure.sourceNodeId,
|
||||
sourcePreviewName: lastStep.exposure.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
||||
const key = `${currentRootGraphId}|${currentHost}|${currentName}`
|
||||
if (visited.has(key)) {
|
||||
console.warn(
|
||||
`[previewExposureChain] cycle detected at ${key}; terminating walk`
|
||||
)
|
||||
return chainFromLastStep()
|
||||
}
|
||||
visited.add(key)
|
||||
|
||||
const exposure = ctx
|
||||
.getExposures(currentRootGraphId, currentHost)
|
||||
.find((e) => e.name === currentName)
|
||||
if (!exposure) return chainFromLastStep()
|
||||
|
||||
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`
|
||||
)
|
||||
return chainFromLastStep()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
@@ -10,20 +11,17 @@ export interface ResolvedPromotedWidget {
|
||||
export interface PromotedWidgetSource {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
}
|
||||
|
||||
export interface PromotedWidgetView extends IBaseWidget {
|
||||
readonly node: SubgraphNode
|
||||
readonly entityId: WidgetEntityId
|
||||
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.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
|
||||
hydrateHostValue(value: IBaseWidget['value']): void
|
||||
|
||||
ensureHostWidgetState(): void
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
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 { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
@@ -20,6 +22,9 @@ import {
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -33,14 +38,6 @@ interface SubgraphSlotRef {
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
type LegacyMouseWidget = IBaseWidget & {
|
||||
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
|
||||
}
|
||||
@@ -56,7 +53,6 @@ export function createPromotedWidgetView(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string,
|
||||
identityName?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
@@ -64,7 +60,6 @@ export function createPromotedWidgetView(
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId,
|
||||
identityName
|
||||
)
|
||||
}
|
||||
@@ -91,16 +86,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
/** Cached reference to the bound subgraph slot, set at construction. */
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
private _lastAutoSeededValue?: IBaseWidget['value']
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string,
|
||||
private readonly identityName?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
@@ -116,6 +111,10 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId {
|
||||
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
return this.yValue
|
||||
}
|
||||
@@ -150,61 +149,78 @@ 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']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
if (!isWidgetValue(value)) return
|
||||
|
||||
const state = this.getWidgetState()
|
||||
const state = this.getHostWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
this._lastAutoSeededValue = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
if (resolved && isWidgetValue(value)) {
|
||||
resolved.widget.value = value
|
||||
this.registerHostWidgetState(value)
|
||||
this._lastAutoSeededValue = undefined
|
||||
}
|
||||
|
||||
ensureHostWidgetState(): void {
|
||||
const fallback = this.fallbackEffectiveValue()
|
||||
const existing = this.getHostWidgetState()
|
||||
|
||||
if (existing) {
|
||||
if (
|
||||
this._lastAutoSeededValue !== undefined &&
|
||||
existing.value === this._lastAutoSeededValue &&
|
||||
isWidgetValue(fallback) &&
|
||||
fallback !== existing.value
|
||||
) {
|
||||
existing.value = fallback
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.registerHostWidgetState(fallback)
|
||||
this._lastAutoSeededValue = fallback
|
||||
}
|
||||
|
||||
private fallbackEffectiveValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
label: this.displayName,
|
||||
serialize: this.serialize,
|
||||
disabled: this.computedDisabled
|
||||
})
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
// Fall back to persisted widget state (survives save/reload before
|
||||
// the slot binding is established) then to construction displayName.
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
}
|
||||
@@ -212,20 +228,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
// Also persist to widget state store for save/reload resilience
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached bound subgraph slot reference, refreshing only when
|
||||
* the subgraph node's input list has changed (length mismatch).
|
||||
*
|
||||
* Note: Using length as the cache key works because the returned reference
|
||||
* is the same mutable slot object. When slot properties (label, name) change,
|
||||
* the caller reads fresh values from that reference. The cache only needs
|
||||
* to invalidate when slots are added or removed, which changes length.
|
||||
*/
|
||||
hydrateHostValue(value: IBaseWidget['value']): void {
|
||||
this.setHostWidgetState(value)
|
||||
}
|
||||
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
@@ -306,7 +316,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
@@ -351,14 +360,34 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
|
||||
}
|
||||
|
||||
afterQueued({
|
||||
isPartialExecution
|
||||
}: { isPartialExecution?: boolean } = {}): void {
|
||||
this.applyValueControlToHost(isPartialExecution)
|
||||
}
|
||||
|
||||
private applyValueControlToHost(isPartialExecution?: boolean): void {
|
||||
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
|
||||
|
||||
const resolved = this.resolveAtHost()
|
||||
const next = nextValueForLinkedTarget({
|
||||
target: this,
|
||||
linkedWidgets: resolved?.widget.linkedWidgets,
|
||||
nodeId: this.subgraphNode.id,
|
||||
isPartialExecution
|
||||
})
|
||||
if (next === undefined) return
|
||||
|
||||
this.hydrateHostValue(next)
|
||||
}
|
||||
|
||||
private resolveAtHost():
|
||||
| { node: LGraphNode; widget: IBaseWidget }
|
||||
| undefined {
|
||||
return resolvePromotedWidgetAtHost(
|
||||
this.subgraphNode,
|
||||
this.sourceNodeId,
|
||||
this.sourceWidgetName,
|
||||
this.disambiguatingSourceNodeId
|
||||
this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -372,8 +401,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 +441,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -542,7 +568,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
|
||||
function isBaseDOMWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { id: string } {
|
||||
|
||||
@@ -9,7 +9,17 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
|
||||
}
|
||||
|
||||
type TestPromotedWidget = IBaseWidget & {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
|
||||
const updatePreviewsMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
@@ -18,12 +28,19 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
getWidgetName,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -34,6 +51,34 @@ function widget(
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a host SubgraphNode whose subgraph contains two source nodes that
|
||||
* share a widget name (`text`), then promotes both — forcing the second
|
||||
* promotion to be disambiguated to `text_1`.
|
||||
*/
|
||||
function buildDuplicateNamePromotion() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
const nodeA = new LGraphNode('SourceA')
|
||||
subgraph.add(nodeA)
|
||||
const inputA = nodeA.addInput('text', 'STRING')
|
||||
const widgetA = nodeA.addWidget('text', 'text', 'a', () => {})
|
||||
inputA.widget = { name: widgetA.name }
|
||||
|
||||
const nodeB = new LGraphNode('SourceB')
|
||||
subgraph.add(nodeB)
|
||||
const inputB = nodeB.addInput('text', 'STRING')
|
||||
const widgetB = nodeB.addWidget('text', 'text', 'b', () => {})
|
||||
inputB.widget = { name: widgetB.name }
|
||||
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, nodeA, widgetA).ok).toBe(true)
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, nodeB, widgetB).ok).toBe(true)
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text', 'text_1'])
|
||||
|
||||
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -112,58 +157,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 +283,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 +347,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,36 +356,86 @@ 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', () => {
|
||||
// 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).
|
||||
describe('autoExposeKnownPreviewNodes', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
updatePreviewsMock.mockReset()
|
||||
})
|
||||
|
||||
it('auto-exposes previews when host has no persisted previewExposures property', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
|
||||
// which eagerly registers $$canvas-image-preview for supported node types
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
|
||||
const store = usePromotionStore()
|
||||
expect(
|
||||
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
|
||||
sourceNodeId: String(glslNode.id),
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
).toBe(true)
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not auto-expose when host has empty persisted previewExposures (user cleared)', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
subgraphNode.properties.previewExposures = []
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not auto-expose when host has non-empty persisted previewExposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const glslNode = new LGraphNode('GLSLShader')
|
||||
glslNode.type = 'GLSLShader'
|
||||
subgraph.add(glslNode)
|
||||
const otherNode = new LGraphNode('OtherShader')
|
||||
otherNode.type = 'GLSLShader'
|
||||
subgraph.add(otherNode)
|
||||
subgraphNode.properties.previewExposures = [
|
||||
{
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
sourceNodeId: String(otherNode.id),
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
}
|
||||
]
|
||||
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore()
|
||||
.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
|
||||
.map((e) => e.sourceNodeId)
|
||||
).not.toContain(String(glslNode.id))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -314,12 +459,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 +560,413 @@ 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)
|
||||
})
|
||||
|
||||
it('updates outer link target_slot when host inputs are reordered', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'STRING' },
|
||||
{ name: 'second', type: 'STRING' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
subgraph.rootGraph.add(host)
|
||||
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'STRING')
|
||||
subgraph.rootGraph.add(source)
|
||||
|
||||
const firstLink = source.connect(0, host, 0)
|
||||
const secondLink = source.connect(0, host, 1)
|
||||
expect(firstLink?.target_slot).toBe(0)
|
||||
expect(secondLink?.target_slot).toBe(1)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
|
||||
expect(firstLink?.target_slot).toBe(1)
|
||||
expect(secondLink?.target_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
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) => widgetSourceNodeId(widget))).toEqual([
|
||||
String(secondNode.id),
|
||||
String(firstNode.id)
|
||||
])
|
||||
expect(host.serialize().widgets_values).toEqual([
|
||||
'second value',
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setupPromotedWidget() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
host.subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('value', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget(
|
||||
'text',
|
||||
'value',
|
||||
'initial',
|
||||
() => {}
|
||||
)
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
const result = promoteValueWidgetViaSubgraphInput(
|
||||
host,
|
||||
interiorNode,
|
||||
interiorWidget
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
return { host, interiorNode, interiorWidget }
|
||||
}
|
||||
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = 9999
|
||||
const promotedViewsBefore = host.widgets.length
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(promotedViewsBefore).toBeGreaterThan(0)
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(host.inputs[0]?.link).toBe(9999)
|
||||
expect(host.inputs[0]?._widget).toBeUndefined()
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(
|
||||
host.widgets.some(
|
||||
(widget) =>
|
||||
widgetSourceNodeId(widget) === String(interiorNode.id) &&
|
||||
widget.name === interiorWidget.name
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('removes the slot entirely when host slot has no external link', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(0)
|
||||
expect(host.inputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('demotes the second of two promoted widgets sharing a source widget name', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
|
||||
const promotedViewForB = host.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(promotedViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(nodeB, promotedViewForB!, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
|
||||
'text',
|
||||
'text_1'
|
||||
])
|
||||
|
||||
const innerViewForB = innerHost.widgets.find(
|
||||
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
|
||||
)
|
||||
expect(innerViewForB!.name).toBe('text_1')
|
||||
|
||||
demoteWidget(innerHost, innerViewForB!, [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disambiguated nested promotion identity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function linkedView(
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): IBaseWidget {
|
||||
return {
|
||||
sourceNodeId,
|
||||
sourceWidgetName,
|
||||
name: sourceWidgetName,
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
y: 0,
|
||||
...overrides
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
function createSubgraphHost() {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
|
||||
const host = createSubgraphHost()
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
|
||||
const interiorNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner'
|
||||
} as unknown as LGraphNode
|
||||
|
||||
const source = {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: getWidgetName(interiorWidget)
|
||||
}
|
||||
|
||||
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'text_1', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
const nestedSubgraphNode = {
|
||||
id: 'inner',
|
||||
title: 'inner',
|
||||
type: 'inner',
|
||||
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
|
||||
} as unknown as LGraphNode
|
||||
subgraph.add(nestedSubgraphNode)
|
||||
|
||||
host.inputs[0]._widget = linkedView('inner', 'text_1')
|
||||
|
||||
pruneDisconnected(host)
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(host.subgraph.inputs[0]?.name).toBe('text_1')
|
||||
})
|
||||
|
||||
it('marks a promoted interior widget as computedDisabled so the connection dot replaces its UI', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
|
||||
interiorNode.updateComputedDisabled()
|
||||
|
||||
expect(interiorWidget.computedDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a real two-level promotion through the SubgraphEditor mount-time prune', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
for (const w of [...innerHost.widgets]) {
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
const beforeCount = outerHost.subgraph.inputs.length
|
||||
expect(beforeCount).toBe(2)
|
||||
|
||||
pruneDisconnected(outerHost)
|
||||
|
||||
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,13 @@ import * as Sentry from '@sentry/vue'
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
IContextMenuValue,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } 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 { reorderSubgraphInputs } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
@@ -15,29 +16,36 @@ 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 { readWidgetValue } from '@/world/widgetValueIO'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
export type WidgetItem = [PartialNode, IBaseWidget]
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
|
||||
return w.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given promotion entry corresponds to a linked promotion
|
||||
* on the subgraph node. Linked promotions are driven by subgraph input
|
||||
* connections and cannot be independently hidden or shown.
|
||||
*/
|
||||
export function isLinkedPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
): boolean {
|
||||
return subgraphNode.inputs.some((input) => {
|
||||
return (
|
||||
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function findHostInputForPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: string,
|
||||
sourceWidgetName: string
|
||||
) {
|
||||
return subgraphNode.inputs.find((input) => {
|
||||
const w = input._widget
|
||||
return (
|
||||
w &&
|
||||
@@ -48,21 +56,125 @@ export function isLinkedPromotion(
|
||||
})
|
||||
}
|
||||
|
||||
export function getSourceNodeId(w: IBaseWidget): string | undefined {
|
||||
if (!isPromotedWidgetView(w)) return undefined
|
||||
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
|
||||
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)
|
||||
}
|
||||
|
||||
function applySubgraphInputOrder(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const widgetValues = subgraphNode.inputs.map((input) =>
|
||||
getExplicitHostWidgetValue(input?._widget)
|
||||
)
|
||||
|
||||
reorderSubgraphInputs(subgraphNode, orderedIndices)
|
||||
|
||||
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
|
||||
const value = widgetValues[oldIndex]
|
||||
if (value === undefined) continue
|
||||
const widget = subgraphNode.inputs[newIndex]?._widget
|
||||
if (widget) widget.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function getExplicitHostWidgetValue(
|
||||
widget: IBaseWidget | undefined
|
||||
): IBaseWidget['value'] | undefined {
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
|
||||
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
|
||||
return (
|
||||
isPromotedWidgetView(left) &&
|
||||
isPromotedWidgetView(right) &&
|
||||
left.sourceNodeId === right.sourceNodeId &&
|
||||
left.sourceWidgetName === right.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
export function isWidgetPromotedOnSubgraphNode(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource,
|
||||
widget?: IBaseWidget
|
||||
): boolean {
|
||||
if (widget && isPreviewPseudoWidget(widget))
|
||||
return isPreviewExposed(subgraphNode, source)
|
||||
return (
|
||||
isLinkedPromotion(
|
||||
subgraphNode,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
) || isPreviewExposed(subgraphNode, source)
|
||||
)
|
||||
}
|
||||
|
||||
function toPromotionSource(
|
||||
node: PartialNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedWidgetSource {
|
||||
const widgetIsParentLevelView =
|
||||
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
|
||||
return {
|
||||
sourceNodeId: String(node.id),
|
||||
sourceWidgetName: getWidgetName(widget),
|
||||
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
|
||||
? widget.disambiguatingSourceNodeId
|
||||
: undefined
|
||||
sourceWidgetName: widgetIsParentLevelView
|
||||
? widget.sourceWidgetName
|
||||
: getWidgetName(widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,18 +186,67 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
|
||||
useCanvasStore().canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Known non-$$ preview widget types added by core or popular extensions. */
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
|
||||
|
||||
/**
|
||||
* Returns true for pseudo-widgets that display media previews and should
|
||||
* be auto-promoted when their node is inside a subgraph.
|
||||
* Matches the core `$$` convention as well as custom-node patterns
|
||||
* (e.g. VHS `videopreview` with type `"preview"`).
|
||||
*/
|
||||
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
|
||||
if (widget.name.startsWith('$$')) return true
|
||||
// Custom nodes may set serialize on the widget or in options
|
||||
if (widget.serialize !== false && widget.options?.serialize !== false)
|
||||
return false
|
||||
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
|
||||
@@ -98,10 +259,21 @@ export function promoteWidget(
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
if (!(node instanceof LGraphNode)) return
|
||||
for (const parent of parents) {
|
||||
store.promote(parent.rootGraph.id, parent.id, source)
|
||||
if (isPreviewPseudoWidget(widget)) {
|
||||
promotePreviewViaExposure(parent, node, source.sourceWidgetName)
|
||||
continue
|
||||
}
|
||||
const result = promoteValueWidgetViaSubgraphInput(parent, node, widget)
|
||||
if (!result.ok) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
level: 'warning',
|
||||
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
|
||||
})
|
||||
}
|
||||
}
|
||||
refreshPromotedWidgetRendering(parents)
|
||||
Sentry.addBreadcrumb({
|
||||
@@ -116,10 +288,45 @@ export function demoteWidget(
|
||||
widget: IBaseWidget,
|
||||
parents: SubgraphNode[]
|
||||
) {
|
||||
const store = usePromotionStore()
|
||||
const source = toPromotionSource(node, widget)
|
||||
for (const parent of parents) {
|
||||
store.demote(parent.rootGraph.id, parent.id, source)
|
||||
if (!parent.subgraph) continue
|
||||
|
||||
const hostInput = findHostInputForPromotion(
|
||||
parent,
|
||||
source.sourceNodeId,
|
||||
source.sourceWidgetName
|
||||
)
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const hasExternalLink = hostInput.link != null
|
||||
if (hasExternalLink) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
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 +359,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) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
options.unshift({
|
||||
@@ -189,10 +395,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) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
|
||||
)
|
||||
if (promotableParents.length > 0)
|
||||
promoteWidget(node, widget, promotableParents)
|
||||
@@ -247,8 +452,8 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
|
||||
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
|
||||
}
|
||||
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
const store = usePromotionStore()
|
||||
export function autoExposeKnownPreviewNodes(subgraphNode: SubgraphNode): void {
|
||||
if (subgraphNode.properties.previewExposures !== undefined) return
|
||||
const { updatePreviews } = useLitegraphService()
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
for (const node of interiorNodes) {
|
||||
@@ -260,88 +465,86 @@ 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).
|
||||
promotePreviewWidget()
|
||||
|
||||
// If a preview widget already exists in this frame, there's nothing to
|
||||
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
|
||||
if (hasPreviewWidget()) continue
|
||||
|
||||
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
|
||||
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
|
||||
// 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
|
||||
}
|
||||
|
||||
// Also schedule a deferred check: core $$ widgets are created lazily by
|
||||
// updatePreviews when node outputs are first loaded.
|
||||
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
|
||||
}
|
||||
}
|
||||
|
||||
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
const interiorNodes = subgraphNode.subgraph.nodes
|
||||
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)
|
||||
)
|
||||
const result = promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
|
||||
if (!result.ok) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
level: 'warning',
|
||||
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
// If the SubgraphInput has any live link to an interior target slot that
|
||||
// still has a widget, the promotion is alive — even when the widget's
|
||||
// sourceNodeId points at a deeply-nested interior node that does not exist
|
||||
// directly in `subgraph` (nested SubgraphNode promotions).
|
||||
for (const linkId of input.linkIds) {
|
||||
const link = subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
const { inputNode } = link.resolve(subgraph)
|
||||
if (!inputNode) continue
|
||||
const targetInputSlot = inputNode.inputs?.find(
|
||||
(slot) => slot.link === linkId
|
||||
)
|
||||
if (!targetInputSlot) continue
|
||||
if (inputNode.getWidgetFromSlot(targetInputSlot)) 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,27 +553,29 @@ 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, {
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
!isWidgetPromotedOnSubgraphNode(
|
||||
subgraphNode,
|
||||
{
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourceWidgetName: widget.name
|
||||
},
|
||||
widget
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,40 +20,29 @@ 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) {
|
||||
return { status: 'failure', failure: 'missing-node' }
|
||||
}
|
||||
|
||||
const sourceWidget = findWidgetByIdentity(
|
||||
sourceNode.widgets,
|
||||
currentWidgetName,
|
||||
currentSourceNodeId
|
||||
const sourceWidget = sourceNode.widgets?.find(
|
||||
(entry) => entry.name === currentWidgetName
|
||||
)
|
||||
if (!sourceWidget) {
|
||||
return { status: 'failure', failure: 'missing-widget' }
|
||||
@@ -73,42 +62,20 @@ function traversePromotedWidgetChain(
|
||||
currentHost = sourceWidget.node
|
||||
currentNodeId = sourceWidget.sourceNodeId
|
||||
currentWidgetName = sourceWidget.sourceWidgetName
|
||||
currentSourceNodeId = undefined
|
||||
}
|
||||
|
||||
return { status: 'failure', failure: 'max-depth-exceeded' }
|
||||
}
|
||||
|
||||
function findWidgetByIdentity(
|
||||
widgets: IBaseWidget[] | undefined,
|
||||
widgetName: string,
|
||||
sourceNodeId?: 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)
|
||||
}
|
||||
|
||||
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 = node.widgets?.find((entry) => entry.name === widgetName)
|
||||
if (!widget) return undefined
|
||||
|
||||
return { node, widget }
|
||||
@@ -117,11 +84,27 @@ 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)
|
||||
}
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function resolvePromotedWidgetSource(
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): ResolvedPromotedWidget | undefined {
|
||||
if (!isPromotedWidgetView(widget)) return undefined
|
||||
if (!hostNode.isSubgraphNode()) return undefined
|
||||
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName,
|
||||
widget.disambiguatingSourceNodeId
|
||||
)
|
||||
if (result.status === 'resolved') return result.resolved
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -85,7 +85,7 @@ describe('resolveSubgraphInputTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
test('resolves non-widget input on nested SubgraphNode to immediate child target', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
@@ -93,28 +93,41 @@ describe('resolveSubgraphInputTarget', () => {
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'audio'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
test('resolves both widget and non-widget inputs on nested SubgraphNodes', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
const { innerSubgraphNode: widthChild } = addLinkedNestedSubgraphNode(
|
||||
outerSubgraph,
|
||||
'width',
|
||||
'width',
|
||||
{ widget: 'width' }
|
||||
)
|
||||
const { innerSubgraphNode: audioChild } = addLinkedNestedSubgraphNode(
|
||||
outerSubgraph,
|
||||
'audio',
|
||||
'audio'
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
nodeId: String(widthChild.id),
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
).toMatchObject({
|
||||
nodeId: String(audioChild.id),
|
||||
widgetName: 'audio'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
@@ -199,7 +212,10 @@ describe('resolveSubgraphInputTarget', () => {
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
).toMatchObject({
|
||||
nodeId: '820',
|
||||
widgetName: 'audio'
|
||||
})
|
||||
})
|
||||
|
||||
test('three-level nesting returns immediate child target, not deepest', () => {
|
||||
|
||||
@@ -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(
|
||||
@@ -18,19 +16,6 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.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')
|
||||
})
|
||||
})
|
||||
32
src/core/schemas/parseNodePropertyArray.ts
Normal file
32
src/core/schemas/parseNodePropertyArray.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
/**
|
||||
* @param property - Raw node property value.
|
||||
* @param schema - Zod schema describing the expected array shape.
|
||||
* @param contextName - Prefix for `console.warn` messages (e.g. `properties.proxyWidgets`).
|
||||
*/
|
||||
export function parseNodePropertyArray<T>(
|
||||
property: NodeProperty | undefined,
|
||||
schema: z.ZodType<T[]>,
|
||||
contextName: string
|
||||
): T[] {
|
||||
if (property === undefined) return []
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = typeof property === 'string' ? JSON.parse(property) : property
|
||||
} catch (e) {
|
||||
console.warn(`Failed to parse ${contextName}:`, e)
|
||||
return []
|
||||
}
|
||||
|
||||
const result = schema.safeParse(parsed)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(`Invalid assignment for ${contextName}:\n${error}`)
|
||||
return []
|
||||
}
|
||||
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([])
|
||||
})
|
||||
})
|
||||
24
src/core/schemas/previewExposureSchema.ts
Normal file
24
src/core/schemas/previewExposureSchema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { parseNodePropertyArray } from './parseNodePropertyArray'
|
||||
|
||||
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[] {
|
||||
return parseNodePropertyArray(
|
||||
property,
|
||||
previewExposuresPropertySchema,
|
||||
'properties.previewExposures'
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
import { parseNodePropertyArray } from './parseNodePropertyArray'
|
||||
|
||||
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(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetsProperty {
|
||||
try {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = proxyWidgetsPropertySchema.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.proxyWidgets:\n${error}`)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.proxyWidgets:', e)
|
||||
}
|
||||
return []
|
||||
return parseNodePropertyArray(
|
||||
property,
|
||||
proxyWidgetsPropertySchema,
|
||||
'properties.proxyWidgets'
|
||||
)
|
||||
}
|
||||
|
||||
68
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
68
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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.for([
|
||||
{ name: 'without hostValue', entry: baseEntry },
|
||||
{ name: 'with hostValue', entry: { ...baseEntry, hostValue: 42 } },
|
||||
{
|
||||
name: 'with 3-tuple originalEntry',
|
||||
entry: { ...baseEntry, originalEntry: ['3', 'text', '1'] }
|
||||
}
|
||||
])('parses a valid entry $name', ({ entry }) => {
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it.for([
|
||||
'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 without warning', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.for([
|
||||
{ name: 'malformed JSON string', input: 'not-json{' },
|
||||
{ name: 'non-array input', input: baseEntry },
|
||||
{
|
||||
name: 'attemptedAtVersion is not 1',
|
||||
input: [{ ...baseEntry, attemptedAtVersion: 2 }]
|
||||
},
|
||||
{
|
||||
name: 'reason is not in the enum',
|
||||
input: [{ ...baseEntry, reason: 'somethingElse' }]
|
||||
},
|
||||
{
|
||||
name: 'originalEntry is malformed',
|
||||
input: [{ ...baseEntry, originalEntry: ['only-one'] }]
|
||||
}
|
||||
])('returns empty array when $name', ({ input }) => {
|
||||
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([])
|
||||
})
|
||||
})
|
||||
45
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
45
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { parseNodePropertyArray } from './parseNodePropertyArray'
|
||||
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[] {
|
||||
return parseNodePropertyArray(
|
||||
property,
|
||||
proxyWidgetErrorQuarantinePropertySchema,
|
||||
'properties.proxyWidgetErrorQuarantine'
|
||||
) as ProxyWidgetErrorQuarantineEntry[]
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
Reroute
|
||||
Reroute,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
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 {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData,
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
@@ -280,17 +282,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 +307,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(
|
||||
[]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -991,6 +987,48 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('warns when configuring a host with legacy proxyWidgets and no migration hook is wired', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sourceHost = createTestSubgraphNode(subgraph)
|
||||
sourceHost.graph!.add(sourceHost)
|
||||
sourceHost.properties.proxyWidgets = [['9999', 'seed']]
|
||||
const serialized = sourceHost.rootGraph.serialize()
|
||||
const instanceData = sourceHost.serialize()
|
||||
|
||||
const previous = LGraph.proxyWidgetMigrationFlush
|
||||
LGraph.proxyWidgetMigrationFlush = undefined
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
LiteGraph.registerNodeType(
|
||||
subgraph.id,
|
||||
class TestSubgraphNode extends SubgraphNode {
|
||||
constructor() {
|
||||
super(new LGraph(), subgraph, instanceData)
|
||||
}
|
||||
}
|
||||
)
|
||||
try {
|
||||
const graph = new LGraph()
|
||||
graph.configure(serialized)
|
||||
|
||||
const migrationCall = warn.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === 'string' &&
|
||||
call[0].includes('Legacy proxyWidgets were not migrated')
|
||||
)
|
||||
expect(migrationCall).toBeDefined()
|
||||
expect(migrationCall![1]).toEqual(
|
||||
expect.objectContaining({
|
||||
hostNodeId: expect.any(Number),
|
||||
proxyWidgets: expect.anything()
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
LGraph.proxyWidgetMigrationFlush = previous
|
||||
LiteGraph.unregisterNodeType(subgraph.id)
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when node ID space is exhausted', () => {
|
||||
expect(() => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -80,6 +76,7 @@ import type {
|
||||
LGraphTriggerHandler,
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
import { LGraphTriggerActions } from './types/graphTriggers'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ExposedWidget,
|
||||
@@ -100,6 +97,12 @@ export type {
|
||||
LGraphTriggerParam
|
||||
} from './types/graphTriggers'
|
||||
|
||||
const validTriggerActions = new Set<LGraphTriggerAction>(LGraphTriggerActions)
|
||||
|
||||
function isLGraphTriggerAction(action: string): action is LGraphTriggerAction {
|
||||
return validTriggerActions.has(action as LGraphTriggerAction)
|
||||
}
|
||||
|
||||
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
|
||||
|
||||
/**
|
||||
@@ -186,6 +189,15 @@ export class LGraph
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** @internal */
|
||||
static proxyWidgetMigrationFlush?: (
|
||||
hostNode: SubgraphNode,
|
||||
nodeData: ISerialisedNode | undefined
|
||||
) => void
|
||||
|
||||
/** @internal */
|
||||
static autoExposePreviewNodes?: (hostNode: SubgraphNode) => void
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -373,7 +385,7 @@ export class LGraph
|
||||
|
||||
const graphId = this.id
|
||||
if (this.isRootGraph && graphId !== zeroUuid) {
|
||||
usePromotionStore().clearGraph(graphId)
|
||||
usePreviewExposureStore().clearGraph(graphId)
|
||||
useWidgetValueStore().clearGraph(graphId)
|
||||
}
|
||||
|
||||
@@ -1337,18 +1349,11 @@ export class LGraph
|
||||
): void
|
||||
trigger(action: string, param: unknown): void
|
||||
trigger(action: string, param: unknown) {
|
||||
// Convert to discriminated union format for typed handlers
|
||||
const validEventTypes = new Set([
|
||||
'node:slot-links:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:property:changed',
|
||||
'node:slot-label:changed'
|
||||
])
|
||||
if (!isLGraphTriggerAction(action)) return
|
||||
if (!param || typeof param !== 'object') return
|
||||
|
||||
if (validEventTypes.has(action) && param && typeof param === 'object') {
|
||||
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
||||
}
|
||||
// Don't handle unknown events - just ignore them
|
||||
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
|
||||
this.events.dispatch(action, param as never)
|
||||
}
|
||||
|
||||
/** @todo Clean up - never implemented. */
|
||||
@@ -1914,13 +1919,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', {
|
||||
@@ -1933,75 +1931,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 }
|
||||
@@ -2722,6 +2651,25 @@ export class LGraph
|
||||
|
||||
this.updateExecutionOrder()
|
||||
|
||||
for (const node of this._nodes) {
|
||||
if (!(node instanceof SubgraphNode)) continue
|
||||
if (node.properties?.proxyWidgets !== undefined) {
|
||||
const nodeData = nodeDataMap.get(node.id)
|
||||
if (LGraph.proxyWidgetMigrationFlush) {
|
||||
LGraph.proxyWidgetMigrationFlush(node, nodeData)
|
||||
} else 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: node.id,
|
||||
proxyWidgets: node.properties.proxyWidgets
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
LGraph.autoExposePreviewNodes?.(node)
|
||||
}
|
||||
|
||||
this.onConfigure?.(data)
|
||||
this.incrementVersion()
|
||||
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode,
|
||||
createUuidv4
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type {
|
||||
ClipboardItems,
|
||||
ExportedSubgraph,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
@@ -99,4 +120,287 @@ describe('remapClipboardSubgraphNodeIds', () => {
|
||||
[String(remappedInteriorId), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
it('remaps pasted SubgraphNode previewExposures sourceNodeId references', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const existingNode = new LGraphNode('existing')
|
||||
existingNode.id = 1
|
||||
rootGraph.add(existingNode)
|
||||
|
||||
const subgraphId = createUuidv4()
|
||||
const pastedSubgraph: ExportedSubgraph = {
|
||||
id: subgraphId,
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 0,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
config: {},
|
||||
name: 'Pasted Subgraph',
|
||||
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
|
||||
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [createSerialisedNode(1, 'test/node')],
|
||||
links: [],
|
||||
groups: []
|
||||
}
|
||||
|
||||
const hostInfo = createSerialisedNode(99, subgraphId)
|
||||
hostInfo.properties = {
|
||||
previewExposures: [
|
||||
{
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const parsed: ClipboardItems = {
|
||||
nodes: [hostInfo],
|
||||
groups: [],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: [pastedSubgraph]
|
||||
}
|
||||
|
||||
remapClipboardSubgraphNodeIds(parsed, rootGraph)
|
||||
|
||||
const remappedInteriorId = parsed.subgraphs?.[0]?.nodes?.[0]?.id
|
||||
expect(remappedInteriorId).not.toBe(1)
|
||||
expect(parsed.nodes?.[0]?.properties?.previewExposures).toStrictEqual([
|
||||
{
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: String(remappedInteriorId),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('_deserializeItems paste-time migration & auto-expose', () => {
|
||||
let originalFlush: typeof LGraph.proxyWidgetMigrationFlush
|
||||
let originalAutoExpose: typeof LGraph.autoExposePreviewNodes
|
||||
const registeredTypesToCleanup: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
originalFlush = LGraph.proxyWidgetMigrationFlush
|
||||
originalAutoExpose = LGraph.autoExposePreviewNodes
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LGraph.proxyWidgetMigrationFlush = originalFlush
|
||||
LGraph.autoExposePreviewNodes = originalAutoExpose
|
||||
for (const type of registeredTypesToCleanup) {
|
||||
LiteGraph.unregisterNodeType(type)
|
||||
}
|
||||
registeredTypesToCleanup.length = 0
|
||||
})
|
||||
|
||||
function registerSubgraphNodeTypeOnCreate(rootGraph: LGraph): void {
|
||||
rootGraph.events.addEventListener('subgraph-created', (e) => {
|
||||
const { subgraph } = e.detail
|
||||
class TestSubgraphNode extends SubgraphNode {
|
||||
constructor() {
|
||||
super(rootGraph, subgraph as Subgraph, {
|
||||
id: -1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
LiteGraph.registerNodeType(subgraph.id, TestSubgraphNode)
|
||||
registeredTypesToCleanup.push(subgraph.id)
|
||||
})
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
el.getBoundingClientRect = vi
|
||||
.fn()
|
||||
.mockReturnValue({ left: 0, top: 0, width: 800, height: 600 })
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
it('clears legacy proxyWidgets on a pasted SubgraphNode and applies host widget values', () => {
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
hostWidgetValues: nodeData?.widgets_values
|
||||
})
|
||||
|
||||
const rootGraph = new LGraph()
|
||||
registerSubgraphNodeTypeOnCreate(rootGraph)
|
||||
const canvas = createCanvas(rootGraph)
|
||||
|
||||
const subgraphId = createUuidv4()
|
||||
const interiorId = 7
|
||||
const pastedSubgraph: ExportedSubgraph = {
|
||||
id: subgraphId,
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: interiorId,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
config: {},
|
||||
name: 'Pasted Subgraph',
|
||||
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
|
||||
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: interiorId,
|
||||
type: 'test/inner',
|
||||
pos: [0, 0],
|
||||
size: [140, 80],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: []
|
||||
}
|
||||
|
||||
const hostInfo: ISerialisedNode = {
|
||||
id: 99,
|
||||
type: subgraphId,
|
||||
pos: [0, 0],
|
||||
size: [140, 80],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: { proxyWidgets: [[String(interiorId), 'seed']] },
|
||||
widgets_values: [42]
|
||||
}
|
||||
|
||||
const parsed: ClipboardItems = {
|
||||
nodes: [hostInfo],
|
||||
groups: [],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: [pastedSubgraph]
|
||||
}
|
||||
|
||||
canvas._deserializeItems(parsed, {})
|
||||
|
||||
const pastedHosts = rootGraph.nodes.filter(
|
||||
(n): n is SubgraphNode => n instanceof SubgraphNode
|
||||
)
|
||||
expect(pastedHosts).toHaveLength(1)
|
||||
expect(pastedHosts[0].properties.proxyWidgets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('auto-exposes preview nodes for pasted subgraphs that lack previewExposures', () => {
|
||||
LGraph.autoExposePreviewNodes = (hostNode) =>
|
||||
autoExposeKnownPreviewNodes(hostNode)
|
||||
|
||||
const rootGraph = new LGraph()
|
||||
registerSubgraphNodeTypeOnCreate(rootGraph)
|
||||
const canvas = createCanvas(rootGraph)
|
||||
|
||||
const subgraphId = createUuidv4()
|
||||
const interiorPreviewId = 5
|
||||
const pastedSubgraph: ExportedSubgraph = {
|
||||
id: subgraphId,
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: interiorPreviewId,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
config: {},
|
||||
name: 'Pasted Subgraph',
|
||||
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
|
||||
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: interiorPreviewId,
|
||||
type: 'PreviewImage',
|
||||
pos: [0, 0],
|
||||
size: [140, 80],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: []
|
||||
}
|
||||
|
||||
const hostInfo: ISerialisedNode = {
|
||||
id: 99,
|
||||
type: subgraphId,
|
||||
pos: [0, 0],
|
||||
size: [140, 80],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
|
||||
const parsed: ClipboardItems = {
|
||||
nodes: [hostInfo],
|
||||
groups: [],
|
||||
reroutes: [],
|
||||
links: [],
|
||||
subgraphs: [pastedSubgraph]
|
||||
}
|
||||
|
||||
canvas._deserializeItems(parsed, {})
|
||||
|
||||
const pastedHost = rootGraph.nodes.find(
|
||||
(n): n is SubgraphNode => n instanceof SubgraphNode
|
||||
)
|
||||
expect(pastedHost).toBeDefined()
|
||||
|
||||
const exposures = usePreviewExposureStore().getExposures(
|
||||
rootGraph.id,
|
||||
String(pastedHost!.id)
|
||||
)
|
||||
const interiorIdAfterRemap = pastedHost!.subgraph.nodes[0].id
|
||||
expect(exposures).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceNodeId: String(interiorIdAfterRemap),
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
} from './measure'
|
||||
import { NodeInputSlot } from './node/NodeInputSlot'
|
||||
import type { Subgraph } from './subgraph/Subgraph'
|
||||
import { topologicalSortSubgraphs } from './subgraph/subgraphDeduplication'
|
||||
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
|
||||
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
import { SubgraphNode } from './subgraph/SubgraphNode'
|
||||
@@ -4191,7 +4192,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
const subgraph = graph.createSubgraph(info)
|
||||
results.subgraphs.set(info.id, subgraph)
|
||||
}
|
||||
for (const info of parsed.subgraphs)
|
||||
const configureOrder = topologicalSortSubgraphs(parsed.subgraphs)
|
||||
for (const info of configureOrder)
|
||||
results.subgraphs.get(info.id)?.configure(info)
|
||||
|
||||
// Groups
|
||||
@@ -4218,6 +4220,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
graph.add(node)
|
||||
node.configure(info)
|
||||
|
||||
if (node instanceof SubgraphNode) {
|
||||
if (
|
||||
node.properties?.proxyWidgets !== undefined &&
|
||||
LiteGraph.LGraph.proxyWidgetMigrationFlush
|
||||
) {
|
||||
LiteGraph.LGraph.proxyWidgetMigrationFlush(node, info)
|
||||
}
|
||||
LiteGraph.LGraph.autoExposePreviewNodes?.(node)
|
||||
}
|
||||
|
||||
created.push(node)
|
||||
}
|
||||
|
||||
@@ -9045,12 +9057,35 @@ 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.
|
||||
*/
|
||||
function hasStringSourceNodeId(
|
||||
value: unknown
|
||||
): value is { sourceNodeId: string } {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'sourceNodeId' in value &&
|
||||
typeof value.sourceNodeId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function remapPreviewExposures(
|
||||
info: ISerialisedNode,
|
||||
remappedIds: Map<NodeId, NodeId> | undefined
|
||||
) {
|
||||
if (!remappedIds || remappedIds.size === 0) return
|
||||
|
||||
const previewExposures = info.properties?.previewExposures
|
||||
if (!Array.isArray(previewExposures)) return
|
||||
|
||||
for (const entry of previewExposures) {
|
||||
if (!hasStringSourceNodeId(entry) || entry.sourceNodeId === '-1') continue
|
||||
|
||||
const remappedNodeId = remapNodeId(entry.sourceNodeId, remappedIds)
|
||||
if (remappedNodeId !== undefined)
|
||||
entry.sourceNodeId = String(remappedNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
export function remapClipboardSubgraphNodeIds(
|
||||
parsed: ClipboardItems,
|
||||
rootGraph: LGraph
|
||||
@@ -9104,6 +9139,8 @@ export function remapClipboardSubgraphNodeIds(
|
||||
|
||||
for (const nodeInfo of allNodeInfo) {
|
||||
if (typeof nodeInfo.type !== 'string') continue
|
||||
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
|
||||
const remappedIds = subgraphNodeIdMap.get(nodeInfo.type)
|
||||
remapProxyWidgets(nodeInfo, remappedIds)
|
||||
remapPreviewExposures(nodeInfo, remappedIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,7 +997,6 @@ export class LGraphNode
|
||||
return o
|
||||
}
|
||||
|
||||
/* Creates a clone of this node */
|
||||
clone(): LGraphNode | null {
|
||||
if (this.type == null) return null
|
||||
const node = LiteGraph.createNode(this.type)
|
||||
@@ -1023,9 +1022,9 @@ export class LGraphNode
|
||||
// @ts-expect-error Exceptional case: id is removed so that the graph can assign a new one on add.
|
||||
data.id = undefined
|
||||
|
||||
if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4()
|
||||
|
||||
node.id = this.id
|
||||
node.configure(data)
|
||||
if (LiteGraph.use_uuids) node.id = LiteGraph.uuidv4()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisedGraph,
|
||||
@@ -48,4 +50,23 @@ export interface LGraphEventMap {
|
||||
subgraph: Subgraph
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
|
||||
'node:property:changed': {
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
oldValue: unknown
|
||||
newValue: unknown
|
||||
}
|
||||
'node:slot-errors:changed': { nodeId: NodeId }
|
||||
'node:slot-links:changed': {
|
||||
nodeId: NodeId
|
||||
slotType: NodeSlotType
|
||||
slotIndex: number
|
||||
connected: boolean
|
||||
linkId: number
|
||||
}
|
||||
'node:slot-label:changed': {
|
||||
nodeId: NodeId
|
||||
slotType?: NodeSlotType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
@@ -31,6 +32,12 @@ export interface SubgraphEventMap extends LGraphEventMap {
|
||||
index: number
|
||||
}
|
||||
|
||||
'inputs-reordered': {
|
||||
subgraph: Subgraph
|
||||
oldOrder: readonly string[]
|
||||
newOrder: readonly string[]
|
||||
}
|
||||
|
||||
'renaming-input': {
|
||||
input: SubgraphInput
|
||||
index: number
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
ISlotType,
|
||||
@@ -8,7 +8,22 @@ import type {
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
makeQuarantineEntry
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
|
||||
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
|
||||
import {
|
||||
createEventCapture,
|
||||
@@ -18,7 +33,13 @@ import {
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
// Helper to create a node with a widget
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetType: TWidgetType = 'number',
|
||||
@@ -46,7 +67,6 @@ function createNodeWithWidget(
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
// Helper to connect subgraph input to node and create SubgraphNode
|
||||
function setupPromotedWidget(
|
||||
subgraph: Subgraph,
|
||||
node: LGraphNode,
|
||||
@@ -57,6 +77,15 @@ function setupPromotedWidget(
|
||||
return createTestSubgraphNode(subgraph)
|
||||
}
|
||||
|
||||
function expectPromotedWidgetView(
|
||||
widget: unknown
|
||||
): asserts widget is PromotedWidgetView {
|
||||
expect(widget).toMatchObject({
|
||||
sourceNodeId: expect.any(String),
|
||||
sourceWidgetName: expect.any(String)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
@@ -291,9 +320,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
hostNode.configure(serializedHostNode)
|
||||
|
||||
expect(hostNode.properties.proxyWidgets).toStrictEqual([
|
||||
[String(interiorNode.id), 'batch_size']
|
||||
])
|
||||
expect(hostNode.widgets).toHaveLength(1)
|
||||
expect(hostNode.widgets[0].name).toBe('batch_size')
|
||||
expect(hostNode.widgets[0].value).toBe(1)
|
||||
@@ -301,11 +327,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
})
|
||||
|
||||
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
|
||||
// Reproduces the bug where packing nodes into a nested subgraph leaves
|
||||
// stale proxyWidgets on the outer subgraph node referencing grandchild
|
||||
// node IDs that no longer exist directly in the outer subgraph.
|
||||
// Uses 3 inputs with only 1 having a linked widget entry, matching the
|
||||
// real workflow structure where model/vae inputs don't resolve widgets.
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'clip', type: 'CLIP' },
|
||||
@@ -335,8 +356,6 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const outerNode = createTestSubgraphNode(subgraph)
|
||||
const keptSamplerNodeId = String(samplerNode.id)
|
||||
|
||||
// Inject stale proxyWidgets referencing nodes that don't exist in
|
||||
// this subgraph (they were packed into a nested subgraph)
|
||||
outerNode.properties.proxyWidgets = [
|
||||
['999', 'text'],
|
||||
['998', 'text'],
|
||||
@@ -356,7 +375,7 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
expect(widgetSourceIds).toContain(keptSamplerNodeId)
|
||||
})
|
||||
|
||||
it('should normalize legacy prefixed proxyWidgets on configure', () => {
|
||||
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
|
||||
const rootGraph = createTestRootGraph()
|
||||
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
@@ -396,21 +415,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[0]?.sourceNodeId).toBe(String(nestedNode.id))
|
||||
expect(hostNode.properties.proxyWidgets).toBeUndefined()
|
||||
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should preserve promoted widget entries after cloning', () => {
|
||||
@@ -427,31 +441,17 @@ 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
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -574,4 +574,807 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
expect(promotedWidget.value).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode.serialize', () => {
|
||||
it('does not mutate interior widget values when serializing the host', () => {
|
||||
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: hostWidget.name,
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
})
|
||||
hostNode.serialize()
|
||||
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
})
|
||||
|
||||
describe('host widget values', () => {
|
||||
type SourceSpec = {
|
||||
inputName: string
|
||||
title: string
|
||||
widgetType: TWidgetType
|
||||
slotType: ISlotType
|
||||
initialValue: unknown
|
||||
withComfyClass?: boolean
|
||||
hugeMaxSeed?: boolean
|
||||
}
|
||||
type EditValue = string | number | boolean
|
||||
type EditSpec = {
|
||||
via: 'viewKey' | 'vue'
|
||||
index: number
|
||||
value: EditValue
|
||||
}
|
||||
type ReorderSpec = { kind: 'none' } | { kind: 'byName'; order: string[] }
|
||||
|
||||
const text = (inputName: string, title: string): SourceSpec => ({
|
||||
inputName,
|
||||
title,
|
||||
widgetType: 'text',
|
||||
slotType: 'STRING',
|
||||
initialValue: '',
|
||||
withComfyClass: true
|
||||
})
|
||||
const num = (
|
||||
inputName: string,
|
||||
title: string,
|
||||
initialValue = 0
|
||||
): SourceSpec => ({
|
||||
inputName,
|
||||
title,
|
||||
widgetType: 'number',
|
||||
slotType: 'number',
|
||||
initialValue
|
||||
})
|
||||
const seed = (): SourceSpec => ({
|
||||
inputName: 'seed',
|
||||
title: 'Sampler',
|
||||
widgetType: 'number',
|
||||
slotType: 'INT',
|
||||
initialValue: 0,
|
||||
withComfyClass: true
|
||||
})
|
||||
|
||||
const TEXT_PAIR: SourceSpec[] = [
|
||||
text('first', 'First'),
|
||||
text('second', 'Second')
|
||||
]
|
||||
const NUMBER_PAIR: SourceSpec[] = [
|
||||
num('first', 'First', 1),
|
||||
num('second', 'Second', 2)
|
||||
]
|
||||
const TEXT_TEXT_SEED: SourceSpec[] = [
|
||||
text('text_1', 'Positive'),
|
||||
text('text', 'Negative'),
|
||||
seed()
|
||||
]
|
||||
|
||||
function buildSources(
|
||||
subgraph: ReturnType<typeof createTestSubgraph>,
|
||||
specs: SourceSpec[]
|
||||
) {
|
||||
const built = specs.map((s) => {
|
||||
const created = createNodeWithWidget(
|
||||
s.title,
|
||||
s.widgetType,
|
||||
s.initialValue,
|
||||
s.slotType
|
||||
)
|
||||
if (s.withComfyClass) created.node.comfyClass = s.title
|
||||
if (s.hugeMaxSeed) created.widget.options.max = 1125899906842624
|
||||
subgraph.add(created.node)
|
||||
return created
|
||||
})
|
||||
for (const [i, s] of specs.entries()) {
|
||||
subgraph
|
||||
.addInput(s.inputName, String(s.slotType))
|
||||
.connect(built[i].input, built[i].node)
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
function vueEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
index: number,
|
||||
value: EditValue
|
||||
) {
|
||||
const widgets = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
widgets[index].updateHandler(value)
|
||||
}
|
||||
|
||||
function applyEdit(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
edit: EditSpec
|
||||
) {
|
||||
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
|
||||
else vueEdit(host, edit.index, edit.value)
|
||||
}
|
||||
|
||||
function applyReorder(
|
||||
host: ReturnType<typeof createTestSubgraphNode>,
|
||||
r: ReorderSpec
|
||||
) {
|
||||
if (r.kind === 'byName') reorderSubgraphInputsByName(host, r.order)
|
||||
}
|
||||
|
||||
function makeControlWidget(
|
||||
value: 'increment' | 'fixed',
|
||||
marker: boolean
|
||||
) {
|
||||
const base = {
|
||||
name: 'control_after_generate',
|
||||
value,
|
||||
serialize: false,
|
||||
beforeQueued: () => {},
|
||||
afterQueued: () => {}
|
||||
}
|
||||
return marker ? { ...base, [IS_CONTROL_WIDGET]: true } : base
|
||||
}
|
||||
|
||||
type ReorderCase = {
|
||||
name: string
|
||||
sources: SourceSpec[]
|
||||
edits: EditSpec[]
|
||||
reorder: ReorderSpec
|
||||
expectedNames?: string[]
|
||||
expectedWidgetsValues?: unknown[]
|
||||
promptByIndex?: Record<number, unknown>
|
||||
}
|
||||
|
||||
const reorderCases: ReorderCase[] = [
|
||||
{
|
||||
name: 'plain numbers via ViewKey, swap by name',
|
||||
sources: NUMBER_PAIR,
|
||||
edits: [
|
||||
{ via: 'viewKey', index: 0, value: 111 },
|
||||
{ via: 'viewKey', index: 1, value: 222 }
|
||||
],
|
||||
reorder: { kind: 'byName', order: ['second', 'first'] },
|
||||
expectedNames: ['second', 'first'],
|
||||
expectedWidgetsValues: [222, 111]
|
||||
},
|
||||
{
|
||||
name: 'plain text via Vue, swap by name (widgets_values + prompt)',
|
||||
sources: TEXT_PAIR,
|
||||
edits: [
|
||||
{ via: 'vue', index: 0, value: 'first value' },
|
||||
{ via: 'vue', index: 1, value: 'second value' }
|
||||
],
|
||||
reorder: { kind: 'byName', order: ['second', 'first'] },
|
||||
expectedWidgetsValues: ['second value', 'first value'],
|
||||
promptByIndex: { 0: 'first value', 1: 'second value' }
|
||||
},
|
||||
{
|
||||
name: 'mixed text/text/seed via ViewKey, swap seed up by name',
|
||||
sources: TEXT_TEXT_SEED,
|
||||
edits: [
|
||||
{ via: 'viewKey', index: 0, value: 'positive prompt' },
|
||||
{ via: 'viewKey', index: 1, value: 'negative prompt' },
|
||||
{ via: 'viewKey', index: 2, value: 123456 }
|
||||
],
|
||||
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
|
||||
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
|
||||
promptByIndex: {
|
||||
0: 'positive prompt',
|
||||
1: 'negative prompt',
|
||||
2: 123456
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mixed text/text/seed via Vue, swap seed up by name',
|
||||
sources: TEXT_TEXT_SEED,
|
||||
edits: [
|
||||
{ via: 'vue', index: 0, value: 'positive prompt' },
|
||||
{ via: 'vue', index: 1, value: 'negative prompt' },
|
||||
{ via: 'vue', index: 2, value: 123456 }
|
||||
],
|
||||
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
|
||||
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
|
||||
promptByIndex: {
|
||||
0: 'positive prompt',
|
||||
1: 'negative prompt',
|
||||
2: 123456
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
it.for(reorderCases)('$name', async (c) => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(subgraph, c.sources)
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
if (c.promptByIndex) {
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
}
|
||||
for (const edit of c.edits) applyEdit(host, edit)
|
||||
applyReorder(host, c.reorder)
|
||||
|
||||
if (c.expectedNames) {
|
||||
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
|
||||
}
|
||||
if (c.expectedWidgetsValues !== undefined) {
|
||||
expect(host.serialize().widgets_values).toEqual(
|
||||
c.expectedWidgetsValues
|
||||
)
|
||||
}
|
||||
if (c.promptByIndex) {
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
for (const [iStr, value] of Object.entries(c.promptByIndex)) {
|
||||
const i = Number(iStr)
|
||||
expect(
|
||||
output[`${host.id}:${sources[i].node.id}`].inputs.value
|
||||
).toBe(value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type ControlCase = {
|
||||
name: string
|
||||
editVia: 'viewKey' | 'vue'
|
||||
controlMode: 'increment' | 'fixed'
|
||||
controlMarker: boolean
|
||||
seedHostValue: number
|
||||
mutateSourceSeedAfterReorder?: number
|
||||
callAfterQueued?: boolean
|
||||
expect: {
|
||||
promptSeed?: number
|
||||
sourceSeed?: number
|
||||
processedSeedValue?: number
|
||||
hostSeedValue?: number
|
||||
storeSeedValue?: number
|
||||
}
|
||||
}
|
||||
|
||||
const controlCases: ControlCase[] = [
|
||||
{
|
||||
name: 'ViewKey + increment: source seed mutation after reorder is ignored in prompt',
|
||||
editVia: 'viewKey',
|
||||
controlMode: 'increment',
|
||||
controlMarker: false,
|
||||
seedHostValue: 123456,
|
||||
mutateSourceSeedAfterReorder: 789,
|
||||
expect: { promptSeed: 123456 }
|
||||
},
|
||||
{
|
||||
name: 'Vue + fixed: host-wins — does not push Vue value into source seed',
|
||||
editVia: 'vue',
|
||||
controlMode: 'fixed',
|
||||
controlMarker: false,
|
||||
seedHostValue: 123456,
|
||||
expect: { sourceSeed: 0 }
|
||||
},
|
||||
{
|
||||
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
|
||||
editVia: 'vue',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 123456,
|
||||
callAfterQueued: true,
|
||||
expect: { processedSeedValue: 123457 }
|
||||
},
|
||||
{
|
||||
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
|
||||
editVia: 'viewKey',
|
||||
controlMode: 'increment',
|
||||
controlMarker: true,
|
||||
seedHostValue: 2,
|
||||
mutateSourceSeedAfterReorder: 8,
|
||||
callAfterQueued: true,
|
||||
expect: { hostSeedValue: 3, storeSeedValue: 3 }
|
||||
}
|
||||
]
|
||||
|
||||
it.for(controlCases)('$name', async (c) => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(
|
||||
subgraph,
|
||||
TEXT_TEXT_SEED.map((s) =>
|
||||
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
|
||||
)
|
||||
)
|
||||
const seed = sources[2]
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
if (c.expect.promptSeed !== undefined) {
|
||||
host.comfyClass = 'Subgraph'
|
||||
host.graph?.add(host)
|
||||
}
|
||||
|
||||
if (c.editVia === 'viewKey') {
|
||||
host.widgets[0].value = 'positive prompt'
|
||||
host.widgets[1].value = 'negative prompt'
|
||||
host.widgets[2].value = c.seedHostValue
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
} else {
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget(c.controlMode, c.controlMarker) as never
|
||||
]
|
||||
vueEdit(host, 2, c.seedHostValue)
|
||||
}
|
||||
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
if (c.mutateSourceSeedAfterReorder !== undefined) {
|
||||
seed.widget.value = c.mutateSourceSeedAfterReorder
|
||||
}
|
||||
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
|
||||
|
||||
if (c.expect.promptSeed !== undefined) {
|
||||
const { output } = await graphToPrompt(host.rootGraph)
|
||||
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(
|
||||
c.expect.promptSeed
|
||||
)
|
||||
}
|
||||
if (c.expect.sourceSeed !== undefined) {
|
||||
expect(seed.widget.value).toBe(c.expect.sourceSeed)
|
||||
}
|
||||
if (c.expect.processedSeedValue !== undefined) {
|
||||
const updated = computeProcessedWidgets({
|
||||
nodeData: extractVueNodeData(host),
|
||||
graphId: host.rootGraph.id,
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
|
||||
})
|
||||
expect(updated[1].value).toBe(c.expect.processedSeedValue)
|
||||
}
|
||||
if (c.expect.hostSeedValue !== undefined) {
|
||||
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
|
||||
}
|
||||
if (c.expect.storeSeedValue !== undefined) {
|
||||
expect(
|
||||
useWidgetValueStore()
|
||||
.getNodeWidgets(host.rootGraph.id, host.id)
|
||||
.find((entry) => entry.name === 'seed')?.value
|
||||
).toBe(c.expect.storeSeedValue)
|
||||
}
|
||||
})
|
||||
|
||||
it('afterQueued does not run value-control when the host input is externally linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(
|
||||
subgraph,
|
||||
TEXT_TEXT_SEED.map((s) =>
|
||||
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
|
||||
)
|
||||
)
|
||||
const seed = sources[2]
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
seed.widget.linkedWidgets = [
|
||||
makeControlWidget('increment', true) as never
|
||||
]
|
||||
host.widgets[2].value = 2
|
||||
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
|
||||
|
||||
const seedSlot = host.getSlotFromWidget(host.widgets[1])
|
||||
expect(seedSlot).toBeDefined()
|
||||
seedSlot!.link = -1
|
||||
|
||||
host.widgets[1].afterQueued?.()
|
||||
|
||||
expect(host.widgets[1].value).toBe(2)
|
||||
})
|
||||
|
||||
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('does not persist source widget store fallback values after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const sources = buildSources(subgraph, TEXT_PAIR)
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const { node, widget } of sources) {
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: node.id,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: `${node.title} 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()
|
||||
buildSources(subgraph, TEXT_PAIR)
|
||||
|
||||
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 [first, second] = reloaded.widgets
|
||||
expectPromotedWidgetView(first)
|
||||
expectPromotedWidgetView(second)
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
|
||||
?.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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewExposures round-trip', () => {
|
||||
const CANVAS = '$$canvas-image-preview'
|
||||
const exposure12 = { sourceNodeId: '12', sourcePreviewName: CANVAS }
|
||||
const exposure14 = {
|
||||
sourceNodeId: '14',
|
||||
sourcePreviewName: 'videopreview'
|
||||
}
|
||||
const named12 = { name: CANVAS, ...exposure12 }
|
||||
const named14 = { name: 'videopreview', ...exposure14 }
|
||||
|
||||
it('hydrates previewExposures into the store during configure', () => {
|
||||
const hostNode = createTestSubgraphNode(createTestSubgraph())
|
||||
hostNode.properties.previewExposures = [
|
||||
{ name: 'preview', ...exposure12 }
|
||||
]
|
||||
hostNode._internalConfigureAfterSlots()
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
hostNode.rootGraph.id,
|
||||
String(hostNode.id)
|
||||
)
|
||||
).toEqual([{ name: 'preview', ...exposure12 }])
|
||||
})
|
||||
|
||||
type SerializeCase = {
|
||||
name: string
|
||||
addExposures: (typeof exposure12)[]
|
||||
staleProperty?: {
|
||||
name: string
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}[]
|
||||
expected: (typeof named12)[] | undefined
|
||||
expectLiveUnchanged?: boolean
|
||||
}
|
||||
|
||||
const serializeCases: SerializeCase[] = [
|
||||
{
|
||||
name: 'writes previewExposures from the store on serialize',
|
||||
addExposures: [exposure12, exposure14],
|
||||
expected: [named12, named14]
|
||||
},
|
||||
{
|
||||
name: 'writes empty previewExposures when the store has no entries for the host',
|
||||
addExposures: [],
|
||||
staleProperty: [
|
||||
{ name: 'stale', sourceNodeId: '0', sourcePreviewName: CANVAS }
|
||||
],
|
||||
expected: [],
|
||||
expectLiveUnchanged: true
|
||||
}
|
||||
]
|
||||
|
||||
it.for(serializeCases)('$name', (c) => {
|
||||
const hostNode = createTestSubgraphNode(createTestSubgraph())
|
||||
if (c.staleProperty)
|
||||
hostNode.properties.previewExposures = c.staleProperty
|
||||
const store = usePreviewExposureStore()
|
||||
for (const e of c.addExposures) {
|
||||
store.addExposure(hostNode.rootGraph.id, String(hostNode.id), e)
|
||||
}
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
expect(serialized.properties?.previewExposures).toEqual(c.expected)
|
||||
if (c.expectLiveUnchanged) {
|
||||
expect(hostNode.properties.previewExposures).toEqual(c.staleProperty)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an explicit empty previewExposures across reload, ignoring legacy locator entries', () => {
|
||||
const hostNode = createTestSubgraphNode(createTestSubgraph())
|
||||
const rootGraphId = hostNode.rootGraph.id
|
||||
const hostLocator = String(hostNode.id)
|
||||
const store = usePreviewExposureStore()
|
||||
|
||||
const serialized = hostNode.serialize()
|
||||
expect(serialized.properties?.previewExposures).toEqual([])
|
||||
|
||||
const legacyKey = createNodeLocatorId(rootGraphId, hostNode.id)
|
||||
store.setExposures(rootGraphId, legacyKey, [
|
||||
{ name: 'legacy', ...exposure12 }
|
||||
])
|
||||
store.setExposures(rootGraphId, hostLocator, [])
|
||||
|
||||
hostNode.properties.previewExposures =
|
||||
serialized.properties?.previewExposures
|
||||
hostNode._internalConfigureAfterSlots()
|
||||
|
||||
expect(store.getExposures(rootGraphId, hostLocator)).toEqual([])
|
||||
})
|
||||
|
||||
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()
|
||||
store.addExposure(
|
||||
firstHost.rootGraph.id,
|
||||
String(firstHost.id),
|
||||
exposure12
|
||||
)
|
||||
store.addExposure(
|
||||
firstHost.rootGraph.id,
|
||||
String(secondHost.id),
|
||||
exposure14
|
||||
)
|
||||
|
||||
const firstExposures =
|
||||
firstHost.serialize().properties?.previewExposures
|
||||
const secondExposures =
|
||||
secondHost.serialize().properties?.previewExposures
|
||||
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures)) {
|
||||
throw new Error('Expected serialized previewExposures arrays')
|
||||
}
|
||||
|
||||
expect(firstExposures).toEqual([named12])
|
||||
expect(secondExposures).toEqual([named14])
|
||||
for (const exposed of [firstExposures[0], secondExposures[0]]) {
|
||||
expect(exposed).not.toHaveProperty('hostInstanceId')
|
||||
expect(exposed).not.toHaveProperty('hostNodeLocator')
|
||||
expect(exposed).not.toHaveProperty('rootGraphId')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyWidgetErrorQuarantine', () => {
|
||||
it('preserves quarantine entries through serialize and is inert at runtime', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
appendQuarantine(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)
|
||||
|
||||
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([])
|
||||
})
|
||||
|
||||
it('restores host values by input name when widgets_values is out of order', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'unet_name', type: 'COMBO' },
|
||||
{ name: 'clip_name', type: 'COMBO' },
|
||||
{ name: 'steps', type: 'INT' }
|
||||
]
|
||||
})
|
||||
|
||||
const unetNode = new LGraphNode('UNETLoader')
|
||||
const unetInput = unetNode.addInput('unet_name', 'COMBO')
|
||||
unetNode.addWidget(
|
||||
'combo',
|
||||
'unet_name',
|
||||
'z_image_turbo_bf16.safetensors',
|
||||
() => {},
|
||||
{ values: ['z_image_turbo_bf16.safetensors'] }
|
||||
)
|
||||
unetInput.widget = { name: 'unet_name' }
|
||||
subgraph.add(unetNode)
|
||||
subgraph.inputNode.slots[0].connect(unetNode.inputs[0], unetNode)
|
||||
|
||||
const clipNode = new LGraphNode('CLIPLoader')
|
||||
const clipInput = clipNode.addInput('clip_name', 'COMBO')
|
||||
clipNode.addWidget(
|
||||
'combo',
|
||||
'clip_name',
|
||||
'qwen_3_4b.safetensors',
|
||||
() => {},
|
||||
{ values: ['qwen_3_4b.safetensors'] }
|
||||
)
|
||||
clipInput.widget = { name: 'clip_name' }
|
||||
subgraph.add(clipNode)
|
||||
subgraph.inputNode.slots[1].connect(clipNode.inputs[0], clipNode)
|
||||
|
||||
const samplerNode = new LGraphNode('KSampler')
|
||||
const stepsInput = samplerNode.addInput('steps', 'INT')
|
||||
samplerNode.addWidget('number', 'steps', 8, () => {})
|
||||
stepsInput.widget = { name: 'steps' }
|
||||
subgraph.add(samplerNode)
|
||||
subgraph.inputNode.slots[2].connect(samplerNode.inputs[0], samplerNode)
|
||||
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const serialized = hostNode.serialize()
|
||||
|
||||
serialized.widgets_values = [
|
||||
8,
|
||||
'qwen_3_4b.safetensors',
|
||||
'z_image_turbo_bf16.safetensors'
|
||||
]
|
||||
serialized.properties = {
|
||||
...serialized.properties,
|
||||
proxyWidgetErrorQuarantine: [
|
||||
{
|
||||
originalEntry: ['-1', 'steps'] as SerializedProxyWidgetTuple,
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 8,
|
||||
attemptedAtVersion: 1
|
||||
},
|
||||
{
|
||||
originalEntry: ['-1', 'clip_name'] as SerializedProxyWidgetTuple,
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'qwen_3_4b.safetensors',
|
||||
attemptedAtVersion: 1
|
||||
},
|
||||
{
|
||||
originalEntry: ['-1', 'unet_name'] as SerializedProxyWidgetTuple,
|
||||
reason: 'missingSourceNode',
|
||||
hostValue: 'z_image_turbo_bf16.safetensors',
|
||||
attemptedAtVersion: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const reloaded = createTestSubgraphNode(subgraph)
|
||||
reloaded.configure(serialized)
|
||||
|
||||
const byName = new Map(
|
||||
reloaded.inputs.map((input) => [input.name, input._widget?.value])
|
||||
)
|
||||
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
|
||||
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')
|
||||
expect(byName.get('steps')).toBe(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,19 +15,10 @@ interface DeduplicationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-deduplicates node IDs across serialized subgraph definitions before
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* Dedupes node IDs across serialized subgraph definitions to prevent widget
|
||||
* store key collisions, and patches any root-level legacy proxyWidgets that
|
||||
* reference the remapped inner IDs. Returns deep clones; inputs are not
|
||||
* mutated. `state.lastNodeId` is advanced.
|
||||
*/
|
||||
export function deduplicateSubgraphNodeIds(
|
||||
subgraphs: ExportedSubgraph[],
|
||||
@@ -197,7 +188,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>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
import type { GraphOrSubgraph } from './Subgraph'
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import { SubgraphInputNode } from './SubgraphInputNode'
|
||||
import type { SubgraphNode } from './SubgraphNode'
|
||||
import type { SubgraphOutput } from './SubgraphOutput'
|
||||
import { SubgraphOutputNode } from './SubgraphOutputNode'
|
||||
|
||||
@@ -480,6 +482,71 @@ export function findUsedSubgraphIds(
|
||||
return usedSubgraphIds
|
||||
}
|
||||
|
||||
function reorderInPlace<T>(arr: T[], indices: readonly number[]): void {
|
||||
arr.splice(0, arr.length, ...indices.flatMap((i) => arr[i] ?? []))
|
||||
}
|
||||
|
||||
function* indexedLinks<S>(
|
||||
slots: readonly S[],
|
||||
resolve: (slot: S) => Iterable<LLink | undefined>
|
||||
): Generator<readonly [number, LLink]> {
|
||||
for (const [index, slot] of slots.entries()) {
|
||||
for (const link of resolve(slot)) {
|
||||
if (link) yield [index, link] as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reorderSubgraphInputs(
|
||||
subgraphNode: SubgraphNode,
|
||||
orderedIndices: readonly number[]
|
||||
): void {
|
||||
const subgraph = subgraphNode.subgraph
|
||||
if (!subgraph) return
|
||||
|
||||
const n = subgraph.inputs.length
|
||||
if (
|
||||
orderedIndices.length !== n ||
|
||||
new Set(orderedIndices).size !== orderedIndices.length ||
|
||||
orderedIndices.some((i) => i < 0 || i >= n)
|
||||
) {
|
||||
console.error(
|
||||
`reorderSubgraphInputs: orderedIndices must be a permutation of 0..${n - 1}`,
|
||||
orderedIndices
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const oldOrder = subgraph.inputs.map((i) => i.id)
|
||||
|
||||
reorderInPlace(subgraph.inputs, orderedIndices)
|
||||
reorderInPlace(subgraphNode.inputs, orderedIndices)
|
||||
subgraphNode.invalidatePromotedViews()
|
||||
|
||||
function* innerLinks(input: SubgraphInput): Generator<LLink | undefined> {
|
||||
for (const id of input.linkIds) yield subgraph.getLink(id)
|
||||
}
|
||||
for (const [slot, link] of indexedLinks(subgraph.inputs, innerLinks)) {
|
||||
link.origin_slot = slot
|
||||
}
|
||||
|
||||
function* outerLink(input: INodeInputSlot): Generator<LLink | undefined> {
|
||||
if (input.link != null) yield subgraphNode.graph?.getLink(input.link)
|
||||
}
|
||||
for (const [slot, link] of indexedLinks(subgraphNode.inputs, outerLink)) {
|
||||
link.target_slot = slot
|
||||
}
|
||||
|
||||
const newOrder = subgraph.inputs.map((i) => i.id)
|
||||
if (!isEqual(oldOrder, newOrder)) {
|
||||
subgraph.events.dispatch('inputs-reordered', {
|
||||
subgraph,
|
||||
oldOrder,
|
||||
newOrder
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a slot is a SubgraphInput.
|
||||
* @param slot The slot to check
|
||||
|
||||
@@ -35,7 +35,14 @@ export type LGraphTriggerEvent =
|
||||
| NodeSlotLinksChangedEvent
|
||||
| NodeSlotLabelChangedEvent
|
||||
|
||||
export type LGraphTriggerAction = LGraphTriggerEvent['type']
|
||||
export const LGraphTriggerActions = [
|
||||
'node:property:changed',
|
||||
'node:slot-errors:changed',
|
||||
'node:slot-links:changed',
|
||||
'node:slot-label:changed'
|
||||
] as const satisfies readonly LGraphTriggerEvent['type'][]
|
||||
|
||||
export type LGraphTriggerAction = (typeof LGraphTriggerActions)[number]
|
||||
|
||||
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
|
||||
LGraphTriggerEvent,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -374,6 +375,14 @@ export interface IRangeWidget extends IBaseWidget<
|
||||
export type TWidgetType = IWidget['type']
|
||||
export type TWidgetValue = IWidget['value']
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
/**
|
||||
* The base type for all widgets. Should not be implemented directly.
|
||||
* @template TValue The type of value this widget holds.
|
||||
@@ -390,6 +399,8 @@ export interface IBaseWidget<
|
||||
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
|
||||
readonly entityId?: WidgetEntityId
|
||||
|
||||
name: string
|
||||
options: TOptions
|
||||
|
||||
|
||||
@@ -17,17 +17,16 @@ 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'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
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[]
|
||||
}
|
||||
@@ -133,6 +132,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
this._state.value = value
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId | undefined {
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const nodeId = this._state.nodeId
|
||||
if (!graphId || nodeId === undefined) return undefined
|
||||
return widgetEntityId(graphId, nodeId, this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates this widget with a node ID and registers it in the WidgetValueStore.
|
||||
* Once set, value reads/writes will be delegated to the store.
|
||||
@@ -206,17 +212,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() {
|
||||
return this.advanced
|
||||
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
|
||||
: LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
@@ -280,13 +276,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
*/
|
||||
protected drawWidgetShape(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText, suppressPromotedOutline }: DrawWidgetOptions
|
||||
{ width, showText }: DrawWidgetOptions
|
||||
): void {
|
||||
const { height, y } = this
|
||||
const { margin } = BaseWidget
|
||||
|
||||
ctx.textAlign = 'left'
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.beginPath()
|
||||
|
||||
@@ -307,7 +303,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
*/
|
||||
protected drawVueOnlyWarning(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, suppressPromotedOutline }: DrawWidgetOptions,
|
||||
{ width }: DrawWidgetOptions,
|
||||
label: string
|
||||
): void {
|
||||
const { y, height } = this
|
||||
@@ -317,7 +313,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ButtonWidget
|
||||
*/
|
||||
override drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
|
||||
{ width, showText = true }: DrawWidgetOptions
|
||||
) {
|
||||
// Store original context attributes
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
@@ -41,7 +41,7 @@ export class ButtonWidget
|
||||
|
||||
// Draw button outline if not disabled
|
||||
if (showText && !this.computedDisabled) {
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(margin, y, width - margin * 2, height)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ChartWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class FileUploadWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class GalleriaWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ImageCompareWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -33,7 +33,7 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
|
||||
|
||||
drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
|
||||
{ width, showText = true }: DrawWidgetOptions
|
||||
): void {
|
||||
// Store original context attributes
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
@@ -145,10 +145,10 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
|
||||
|
||||
// Draw outline if not disabled
|
||||
if (showText && !this.computedDisabled) {
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
// Draw value
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.arc(
|
||||
arc_center.x,
|
||||
arc_center.y,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MarkdownWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MultiSelectWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -23,7 +23,7 @@ export class SelectButtonWidget
|
||||
ctx.fillStyle = this.background_color
|
||||
ctx.fillRect(15, y, width - 30, height)
|
||||
|
||||
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(15, y, width - 30, height)
|
||||
|
||||
ctx.fillStyle = this.text_color
|
||||
|
||||
@@ -20,7 +20,7 @@ export class SliderWidget
|
||||
*/
|
||||
override drawWidget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
|
||||
{ width, showText = true }: DrawWidgetOptions
|
||||
) {
|
||||
// Store original context attributes
|
||||
const { fillStyle, strokeStyle, textAlign } = ctx
|
||||
@@ -43,7 +43,7 @@ export class SliderWidget
|
||||
|
||||
// Draw outline if not disabled
|
||||
if (showText && !this.computedDisabled) {
|
||||
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
|
||||
ctx.strokeStyle = this.getOutlineColor()
|
||||
ctx.strokeRect(margin, y, width - margin * 2, height)
|
||||
}
|
||||
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -13,6 +13,9 @@ import { VueFire, VueFireAuth } from 'vuefire'
|
||||
|
||||
import { setAssertReporter } from '@/base/assert'
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
@@ -127,6 +130,15 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
hostWidgetValues: nodeData?.widgets_values
|
||||
})
|
||||
|
||||
LGraph.autoExposePreviewNodes = (hostNode) =>
|
||||
autoExposeKnownPreviewNodes(hostNode)
|
||||
|
||||
const bootstrapStore = useBootstrapStore(pinia)
|
||||
void bootstrapStore.startStoreBootstrap()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user