Compare commits

...

25 Commits

Author SHA1 Message Date
DrJKL
488406846d fix: remove promoted source alias
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 17:37:57 -07:00
DrJKL
bfc21fbb72 fix: carry NodeId through promoted widgets
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 17:32:30 -07:00
DrJKL
cb53ea1e6a fix: remove internal NodeId rebranding
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 17:18:52 -07:00
DrJKL
7cdbd5af6e test: simplify NodeId fixture setup
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 16:49:56 -07:00
DrJKL
53b08c5027 test: use NodeId in vue node interaction tests
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 16:40:00 -07:00
DrJKL
1336524260 fix: type node output APIs with NodeId
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 16:28:20 -07:00
DrJKL
77351e6bcd fix: separate node and execution id traversal
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 16:20:50 -07:00
DrJKL
d9a9fb009c fix: type promoted preview source ids
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 16:05:02 -07:00
DrJKL
7aed2c9e32 fix: type slot link interaction with NodeId
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 15:30:43 -07:00
DrJKL
0eb948ec00 fix: carry NodeId through slot tracking
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 15:22:25 -07:00
DrJKL
159815d37e fix: trust NodeId in layout store APIs
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 15:04:56 -07:00
DrJKL
938584122c fix: keep graph lookup NodeId-only
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ef128-72d8-76a5-9107-428c35e76e1a
2026-06-22 14:50:25 -07:00
DrJKL
e39f93d22b refactor: share next-free-node-id allocator across configure paths
Three copies of the "walk the graph counter to the next unused id" loop
existed (string-id normalization, subgraph dedup, ensureGlobalIdUniqueness).
Extract a single nextFreeNodeId helper and MAX_NODE_ID into a leaf module
and route all three through it. Behavior preserved; the global-uniqueness
path now also honors the MAX_NODE_ID ceiling.

Amp-Thread-ID: https://ampcode.com/threads/T-019ef0e3-ef59-7306-9cfe-7943fed86791
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 14:02:01 -07:00
DrJKL
dc9b1db3e7 fix: normalize legacy string node ids to numeric on load
Workflows persisted with non-integer string node ids (e.g.
"CheckpointLoaderSimple.0") only exist in the legacy v0.4 schema and
threw at asNodeId under the branded numeric NodeId contract, breaking
load/render.

Reconcile them in a single pre-pass at the top of LGraph.configure:
allocate a fresh numeric id from the graph counter (the same mechanism
add() uses for unassigned nodes) and patch array-link endpoints, before
any LLink or LGraphNode is constructed. Newer numeric-id schemas pass
through untouched.

Amp-Thread-ID: https://ampcode.com/threads/T-019ef0e3-ef59-7306-9cfe-7943fed86791
Co-authored-by: Amp <amp@ampcode.com>
2026-06-22 13:52:42 -07:00
DrJKL
dcea64e420 test: Update expectations to be numeric. 2026-06-22 13:45:36 -07:00
DrJKL
aa25c3f92e Merge remote-tracking branch 'origin/main' into drjkl/now-for-nodes-no-groups
# Conflicts:
#	src/platform/workflow/management/stores/comfyWorkflow.ts
2026-06-22 12:51:49 -07:00
DrJKL
4dcd0fc7ff fix: align nodeIdToNumber input strictness with asNodeId
nodeIdToNumber used bare Number(), silently accepting hex and scientific
notation that asNodeId rejects via DECIMAL_INTEGER. Reject non-decimal
strings so the two parsers agree even for future unguarded callers.

Amp-Thread-ID: https://ampcode.com/threads/T-019edd35-b5b0-7048-be21-c704ce1e46ef
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 19:22:36 -07:00
DrJKL
974975c722 fix: harden NodeId boundaries against strict asNodeId
Address CodeRabbit review on the branded-number NodeId refactor:

- Add non-throwing tryAsNodeId / tryStripGraphPrefix helpers and use them at
  untrusted boundaries (clipboard paste remap, link-drop, slot-hover, widget
  processing) so invalid metadata is skipped instead of crashing.
- Use nullish checks for numeric NodeId guards so node id 0 is handled.
- Constrain last_node_id to a non-negative integer.
- Drop unsound Record<NodeId, NodeError> casts; key node errors by string.
- Normalize the 'executing' event payload via asNodeExecutionId.
- Brand storeJob node keys as NodeExecutionId.
- Restore layoutStore currentSource via try/finally.
- Clarify isNodeExecutionId JSDoc and ADR 0008 NodeId primitive.
- Fix tests comparing/keying NodeId values as strings.

Amp-Thread-ID: https://ampcode.com/threads/T-019edd35-b5b0-7048-be21-c704ce1e46ef
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 18:05:56 -07:00
DrJKL
c567acbcb2 refactor: make NodeId a branded number
Flip the NodeId baseline from a branded string to a branded number and
make asNodeId strict (decimal integers only, valid negative sentinels
aside). Stringify only at Yjs/DOM/string-key boundaries; layoutStore
public API stays NodeId-typed.

Fix real conflations surfaced by the strict parser:
- SubgraphNode.getInputLink no longer stuffs a colon execution path into
  LLink.origin_id; it returns the inner link copy.
- nodeIdentification guards/parsers require numeric local segments;
  locator/execution ids keep their branded-string forms.
- LGraphNodePreview uses UNASSIGNED_NODE_ID instead of a synthetic id.
- ResultItem.nodeId is typed as NodeExecutionId (execution-path address).

Convert test fixtures from descriptive string node ids to numeric ids
and update expectations/snapshots accordingly.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
2026-06-18 15:03:33 -07:00
DrJKL
9581c75680 refactor: brand NodeLocatorId to de-conflate from NodeId entity
NodeLocatorId is a derived address (subgraph-definition UUID + local id),
not an ECS Entity ID. Brand it like NodeExecutionId so it can never be
assigned where a NodeId is expected, and add lenient asNodeLocatorId for
root-graph nodes and persistence/boundary reads.

Producers branded: createNodeLocatorId, workflowStore locator converters,
getLocatorIdFromNodeData, executionIdToNodeLocatorId.

Also fixes a latent bug in nodeOutputStore.revokeSubgraphPreviews where
inner-subgraph locator ids were built without the ":" separator
("<uuid><id>" instead of "<uuid>:<id>"); now uses createNodeLocatorId.

Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:43 -07:00
DrJKL
8e49de8398 refactor: type API prompt link tuples as NodeExecutionId
Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:42 -07:00
DrJKL
51759157d8 refactor: type workflow flattening and missing-asset scans as NodeExecutionId
Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:42 -07:00
DrJKL
d7435f038d refactor: retype WS/API execution fields off NodeId to NodeExecutionId
Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:41 -07:00
DrJKL
153a790781 refactor: brand NodeExecutionId to de-conflate from NodeId entity
Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:41 -07:00
DrJKL
d23a594de1 refactor: brand and tighten NodeId typing onto group-node removal
Replays the NodeId Entity-ID branding work from drjkl/now-for-nodes onto
the #12931 group-node-removal branch. Group-node composite ids and the
deleted executableGroupNodeChildDTO are dropped; the new groupNode.test.ts
link/id literals are branded via asNodeId.

Amp-Thread-ID: https://ampcode.com/threads/T-019ed8d4-c55a-7683-b7df-f4fbaf177fc3
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 15:02:40 -07:00
302 changed files with 4042 additions and 2513 deletions

View File

@@ -5,10 +5,9 @@ import type {
LGraph,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId, NodeIdInput } from '@/types/nodeId'
import { asNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { Position, Size } from '@e2e/fixtures/types'
@@ -42,11 +41,12 @@ export class NodeOperationsHelper {
}
async getSelectedNodeIds(): Promise<NodeId[]> {
return await this.page.evaluate(() => {
const ids = await this.page.evaluate(() => {
const selected = window.app?.canvas?.selected_nodes
if (!selected) return []
return Object.keys(selected).map(Number)
return Object.keys(selected)
})
return ids.map(asNodeId)
}
/**
@@ -114,7 +114,7 @@ export class NodeOperationsHelper {
return this.getNodeRefById(id)
}
async getNodeRefById(id: NodeId): Promise<NodeReference> {
async getNodeRefById(id: NodeIdInput): Promise<NodeReference> {
return new NodeReference(id, this.comfyPage)
}

View File

@@ -1,7 +1,8 @@
import { expect } from '@playwright/test'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId, NodeIdInput } from '@/types/nodeId'
import { asNodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Position, Size } from '@e2e/fixtures/types'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
@@ -298,10 +299,13 @@ class NodeWidgetReference {
}
}
export class NodeReference {
readonly id: NodeId
constructor(
readonly id: NodeId,
id: NodeIdInput,
readonly comfyPage: ComfyPage
) {}
) {
this.id = asNodeId(id)
}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)

View File

@@ -1,6 +1,7 @@
import { mergeTests } from '@playwright/test'
import type { NodeError } from '@/schemas/apiSchema'
import { asNodeId } from '@/types/nodeId'
import {
comfyExpect as expect,
comfyPageFixture
@@ -12,7 +13,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const VALIDATION_ERROR_NODE_ID = '1'
const VALIDATION_ERROR_NODE_ID = asNodeId('1')
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']

View File

@@ -31,7 +31,7 @@ test.describe('Preview as Text node', () => {
})
const previewEntry = Object.values(apiWorkflow).find(
(n) => n.class_type === 'PreviewAny'
(n) => n?.class_type === 'PreviewAny'
)
expect(previewEntry).toBeDefined()

View File

@@ -3,16 +3,17 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { NodeId } from '@/types/nodeId'
import type { Position } from '@e2e/fixtures/types'
type NodeSnapshot = { id: number } & Position
type NodeSnapshot = { id: NodeId } & Position
async function getAllNodePositions(
comfyPage: ComfyPage
): Promise<NodeSnapshot[]> {
return comfyPage.page.evaluate(() =>
window.app!.graph.nodes.map((n) => ({
id: n.id as number,
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
@@ -21,7 +22,7 @@ async function getAllNodePositions(
async function getNodePosition(
comfyPage: ComfyPage,
nodeId: number
nodeId: NodeId
): Promise<Position | undefined> {
return comfyPage.page.evaluate((targetNodeId) => {
const node = window.app!.graph.nodes.find((n) => n.id === targetNodeId)

View File

@@ -3,6 +3,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { asNodeExecutionId } from '@/types/nodeIdentification'
/**
* Expanded folder view must drop output records that resolve to the same
@@ -12,7 +13,7 @@ import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
*/
const STACK_JOB_ID = 'job-output-dedupe'
const COVER_NODE_ID = '9'
const COVER_NODE_ID = asNodeExecutionId('9')
const COVER_FILENAME = 'cover_00001_.png'
const DUPLICATE_FILENAME = 'duplicate_00002_.png'
const DISTINCT_FILENAMES = ['distinct_00003_.png', 'distinct_00004_.png']

View File

@@ -9,6 +9,7 @@ import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { asNodeId } from '@/types/nodeId'
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
@@ -71,7 +72,7 @@ const SAMPLE_IMPORTED_FILES = [
const JOB_GAMMA_DETAIL: JobDetail = {
...SAMPLE_JOBS[2],
outputs: {
'3': {
[asNodeId('3')]: {
images: [
{
filename: 'abstract_art.png',

View File

@@ -11,6 +11,7 @@ import type {
JobDetail,
RawJobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { asNodeId } from '@/types/nodeId'
const test = mergeTests(comfyPageFixture, jobsRouteFixture)
@@ -72,7 +73,7 @@ const multiOutputJob = createRouteMockJob({
const multiOutputJobDetail: JobDetail = {
...multiOutputJob,
outputs: {
'3': {
[asNodeId('3')]: {
images: [
{
filename: 'multi-output-a.png',

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
interface SubgraphNodePosition {

View File

@@ -568,10 +568,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
const allIds = allGraphs.flatMap((g) => g._nodes).map((n) => n.id)
return { allIds, uniqueCount: new Set(allIds).size }
})
@@ -589,8 +586,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
.sort((a, b) => Number(a) - Number(b))
})
expect(rootIds).toEqual([1, 2, 5])
@@ -633,9 +629,9 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
)
]
const SENTINEL_IDS = new Set([-1, -10, -20])
const isSentinelNodeId = (id: number | string): id is number =>
typeof id === 'number' && SENTINEL_IDS.has(id)
const SENTINEL_IDS = new Set(['-1', '-10', '-20'])
const isSentinelNodeId = (id: number | string): boolean =>
SENTINEL_IDS.has(String(id))
const checkEndpoint = (
label: string,
@@ -644,7 +640,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
g: typeof graph
): string | null => {
if (isSentinelNodeId(id)) return null
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
if (!g.getNodeById(id)) {
return `${label}: ${kind} ${id} invalid or not found`
}
return null

View File

@@ -1,7 +1,7 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import type { NodeId } from '@/types/nodeId'
import {
comfyExpect as expect,
comfyPageFixture as test

View File

@@ -1,9 +1,7 @@
import type { Page, Request } from '@playwright/test'
import type {
ComfyApiWorkflow,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
@@ -146,7 +144,7 @@ test.describe('Workflow settings', { tag: '@canvas' }, () => {
)
}
function ascendingById(ids: NodeId[]): NodeId[] {
function ascendingById<T extends string | number>(ids: T[]): T[] {
return [...ids].sort((a, b) => Number(a) - Number(b))
}

View File

@@ -13,12 +13,13 @@ import type {
RawJobListItem,
zJobsListResponse
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { asNodeExecutionId } from '@/types/nodeIdentification'
type JobsListResponse = z.infer<typeof zJobsListResponse>
const test = mergeTests(comfyPageFixture, webSocketFixture)
const KSAMPLER_NODE = '3'
const KSAMPLER_NODE = asNodeExecutionId('3')
const EXECUTING_CLASS = /outline-node-stroke-executing/
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/

View File

@@ -36,34 +36,34 @@ Adopt an Entity Component System architecture for the graph domain model. This A
Six entity kinds, each with a branded ID type:
| Entity Kind | Current Class(es) | Current ID | Branded ID |
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
| Entity Kind | Current Class(es) | Current ID | Branded ID |
| ----------- | ------------------------------------------------- | --------------------------- | ----------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeId` |
| Link | `LLink` | `LinkId = number` | `LinkId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteId` |
| Group | `LGraphGroup` | `number` | `GroupId` |
Subgraphs are not a separate entity kind. A subgraph is a node with a `SubgraphStructure` component. See [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) for the full design rationale.
### Branded ID Design
Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types:
Each entity kind gets a nominal/branded type wrapping its underlying primitive, and the brand prevents accidental cross-kind usage at compile time. `NodeId` is a branded `number`: node ids are normalized from legacy `number | string` values to `number` at load/serialization boundaries (via `asNodeId`, which rejects non-decimal-integer strings). `WidgetId` is a branded `string` composite path. The remaining ids keep `number` as their underlying primitive:
```ts
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type NodeId = number & { readonly __brand: 'NodeId' }
type LinkId = number & { readonly __brand: 'LinkId' }
type WidgetId = string & { readonly __brand: 'WidgetId' }
type SlotId = number & { readonly __brand: 'SlotId' }
type RerouteId = number & { readonly __brand: 'RerouteId' }
type GroupId = number & { readonly __brand: 'GroupId' }
// Scope identifier, not an entity ID
type GraphId = string & { readonly __brand: 'GraphId' }
```
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
Widgets and Slots currently lack independent IDs. A `WidgetId` is a composite path string derived from its `graphId`, parent `NodeId`, and widget name (stable across instances of the same node). Slots get synthetic IDs assigned at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
### Component Decomposition

View File

@@ -12,7 +12,8 @@ import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelec
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetId } from '@/types/widgetId'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,

View File

@@ -8,6 +8,7 @@ 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 { UNASSIGNED_NODE_ID } from '@/types/nodeId'
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'
@@ -75,7 +76,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
matchingWidget.nodeId = node.id
return [
{
@@ -139,7 +140,7 @@ async function handleDragDrop() {
return false
}
app.dragOverNode = { id: -1, onDragDrop }
app.dragOverNode = { id: UNASSIGNED_NODE_ID, onDragDrop }
}
defineExpose({ handleDragDrop })

View File

@@ -5,6 +5,7 @@ import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ErrorOverlay from './ErrorOverlay.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeError } from '@/schemas/apiSchema'
import type {
@@ -153,7 +154,7 @@ describe('ErrorOverlay', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
[asNodeId('1')]: makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()
@@ -190,7 +191,7 @@ describe('ErrorOverlay', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
'1': makeNodeError(['Only error'])
[asNodeId('1')]: makeNodeError(['Only error'])
}
executionErrorStore.showErrorOverlay()
await nextTick()

View File

@@ -5,6 +5,7 @@ import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useErrorOverlayState } from './useErrorOverlayState'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { NodeError } from '@/schemas/apiSchema'
@@ -250,7 +251,7 @@ describe('useErrorOverlayState', () => {
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastExecutionError = {
prompt_id: 'prompt',
node_id: 1,
node_id: asNodeExecutionId(1),
node_type: 'KSampler',
executed: [],
exception_message: 'CUDA out of memory',
@@ -276,7 +277,7 @@ describe('useErrorOverlayState', () => {
name: 'image.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -285,7 +286,7 @@ describe('useErrorOverlayState', () => {
},
referencingNodes: [
{
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'LoadImage',
widgetName: 'image'
}
@@ -312,7 +313,7 @@ describe('useErrorOverlayState', () => {
const missingMediaStore = useMissingMediaStore()
missingMediaStore.setMissingMedia([
{
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -338,7 +339,7 @@ describe('useErrorOverlayState', () => {
{
name: 'missing.safetensors',
representative: {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'missing.safetensors',
@@ -347,8 +348,8 @@ describe('useErrorOverlayState', () => {
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'ckpt_name' },
{ nodeId: '2', widgetName: 'ckpt_name' }
{ nodeId: asNodeExecutionId('1'), widgetName: 'ckpt_name' },
{ nodeId: asNodeExecutionId('2'), widgetName: 'ckpt_name' }
]
}
]
@@ -420,7 +421,7 @@ describe('useErrorOverlayState', () => {
{
name: 'first.safetensors',
representative: {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'first.safetensors',
@@ -428,12 +429,14 @@ describe('useErrorOverlayState', () => {
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '1', widgetName: 'ckpt_name' }]
referencingNodes: [
{ nodeId: asNodeExecutionId('1'), widgetName: 'ckpt_name' }
]
},
{
name: 'second.safetensors',
representative: {
nodeId: '2',
nodeId: asNodeExecutionId('2'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'second.safetensors',
@@ -441,7 +444,9 @@ describe('useErrorOverlayState', () => {
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId: '2', widgetName: 'ckpt_name' }]
referencingNodes: [
{ nodeId: asNodeExecutionId('2'), widgetName: 'ckpt_name' }
]
}
]
}

View File

@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { BaseDOMWidget } from '@/scripts/domWidget'
@@ -21,7 +21,7 @@ function createNode(
pos: [number, number]
) {
const node = new LGraphNode(title)
node.id = id
node.id = asNodeId(id)
node.pos = [...pos]
node.size = [240, 120]
graph.add(node)

View File

@@ -73,7 +73,8 @@
:key="nodeData.id"
:node-data="nodeData"
:error="
executionErrorStore.lastExecutionError?.node_id === nodeData.id
executionErrorStore.lastExecutionErrorNodeLocatorId ===
getLocatorIdFromNodeData(nodeData)
? 'Execution error'
: null
"
@@ -189,7 +190,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
import {
forEachNode,
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'

View File

@@ -35,13 +35,14 @@ const SkeletonStub = defineComponent({
function renderPreview(
text: string,
{ nodeId = 'node-1' }: { nodeId?: string | number } = {}
{ executionId = null }: { executionId?: string | null } = {}
) {
const value = ref(text)
const Harness = defineComponent({
components: { TextPreviewWidget },
setup: () => ({ value, nodeId }),
template: '<TextPreviewWidget v-model="value" :node-id="nodeId" />'
setup: () => ({ value, executionId }),
template:
'<TextPreviewWidget v-model="value" :execution-id="executionId" />'
})
return render(Harness, {
global: {
@@ -167,21 +168,21 @@ describe('TextPreviewWidget', () => {
it('hides the Skeleton on mount when execution is already idle', () => {
execState().executingNodeIds = []
execState().isIdle = true
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { executionId: 'n1' })
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('shows a Skeleton on mount when the parent node is executing', () => {
it('shows a Skeleton on mount when the node is executing', () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { executionId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
it('hides the Skeleton when execution transitions to idle', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { executionId: 'n1' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
execState().executingNodeIds = []
@@ -191,15 +192,36 @@ describe('TextPreviewWidget', () => {
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('hides the Skeleton when the parent node leaves executingNodeIds', async () => {
it('hides the Skeleton when the node leaves executingNodeIds', async () => {
execState().executingNodeIds = ['n1']
execState().isIdle = false
renderPreview('text', { nodeId: 'n1' })
renderPreview('text', { executionId: 'n1' })
execState().executingNodeIds = ['other']
await nextTick()
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('matches subgraph execution ids by full path, not local id', () => {
execState().executingNodeIds = ['65:18']
execState().isIdle = false
renderPreview('text', { executionId: '65:18' })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
it('does not match when only the local id collides across subgraphs', () => {
execState().executingNodeIds = ['18']
execState().isIdle = false
renderPreview('text', { executionId: '65:18' })
expect(screen.queryByTestId('skeleton')).toBeNull()
})
it('falls back to any executing node when executionId is unknown', () => {
execState().executingNodeIds = ['anything']
execState().isIdle = false
renderPreview('text', { executionId: null })
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
})
})
})

View File

@@ -14,22 +14,22 @@
<script setup lang="ts">
import { default as DOMPurify } from 'dompurify'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, watch } from 'vue'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const props = defineProps<{
nodeId: NodeId
const { executionId } = defineProps<{
executionId: NodeExecutionId | null
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = computed(() => {
if (executionStore.isIdle) return false
if (!parentNodeId) return executionStore.executingNodeIds.length > 0
return executionStore.executingNodeIds.includes(parentNodeId)
if (!executionId) return executionStore.executingNodeIds.length > 0
return executionStore.executingNodeIds.includes(executionId)
})
const formattedText = computed(() => {
const src = modelValue.value
@@ -64,19 +64,4 @@ const formattedText = computed(() => {
ALLOWED_ATTR: ['href', 'target', 'rel']
})
})
let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId ?? parentNodeId
})
// Lazily adopt the first executing node as the parent when no nodeId is known.
watch(
() => executionStore.executingNodeIds,
(ids) => {
if (!parentNodeId && ids.length > 0) parentNodeId = ids[0]
}
)
</script>

View File

@@ -135,7 +135,7 @@ import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'

View File

@@ -5,6 +5,7 @@ import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import Load3D from '@/components/load3d/Load3D.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { ComponentWidget } from '@/scripts/domWidget'
const { load3dState, resolveNodeMock, settingGetMock } = vi.hoisted(() => ({
@@ -88,7 +89,7 @@ type RenderOptions = {
enable3DViewer?: boolean
}
const MOCK_NODE = { id: 'node', type: 'Load3D' }
const MOCK_NODE = { id: 1, type: 'Load3D' }
function renderLoad3D(options: RenderOptions = {}) {
const stub = buildLoad3dStub()
@@ -109,7 +110,8 @@ function renderLoad3D(options: RenderOptions = {}) {
widget: (options.widget ?? {
node: MOCK_NODE
}) as unknown as ComponentWidget<string[]>,
nodeId: options.nodeId
nodeId:
options.nodeId === undefined ? undefined : asNodeId(options.nodeId)
},
global: {
plugins: [i18n],

View File

@@ -115,7 +115,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useLoad3d } from '@/composables/useLoad3d'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -144,7 +144,7 @@ const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
} else if (nodeId != null) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
})

View File

@@ -22,10 +22,11 @@ vi.mock('@/components/load3d/Load3D.vue', () => ({
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
const MOCK_NODE = { id: 1, type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
@@ -39,9 +40,11 @@ describe('Load3DAdvanced', () => {
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
const widget = { node: { id: 1, type: 'Load3DAdvanced' } }
render(Load3DAdvanced, {
props: { widget: widget as never, nodeId: asNodeId(1) }
})
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
expect(lastProps.value?.nodeId).toBe(1)
})
})

View File

@@ -10,7 +10,7 @@
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '@/types/nodeId'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import type {
JobListItem,
JobStatus
@@ -154,7 +155,7 @@ export const Queued: Story = {
value: 1,
max: 1,
state: 'running',
node_id: '1',
node_id: asNodeExecutionId('1'),
prompt_id: 'p1'
}
}
@@ -204,7 +205,7 @@ export const QueuedParallel: Story = {
value: 1,
max: 2,
state: 'running',
node_id: '1',
node_id: asNodeExecutionId('1'),
prompt_id: 'p1'
}
},
@@ -213,7 +214,7 @@ export const QueuedParallel: Story = {
value: 1,
max: 2,
state: 'running',
node_id: '2',
node_id: asNodeExecutionId('2'),
prompt_id: 'p2'
}
}
@@ -254,7 +255,7 @@ export const Running: Story = {
value: 5,
max: 10,
state: 'running',
node_id: '1',
node_id: asNodeExecutionId('1'),
prompt_id: 'p1'
}
}
@@ -299,7 +300,7 @@ export const QueuedZeroAheadSingleRunning: Story = {
value: 1,
max: 3,
state: 'running',
node_id: '1',
node_id: asNodeExecutionId('1'),
prompt_id: 'p1'
}
}
@@ -347,7 +348,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
value: 2,
max: 5,
state: 'running',
node_id: '1',
node_id: asNodeExecutionId('1'),
prompt_id: 'p1'
}
},
@@ -356,7 +357,7 @@ export const QueuedZeroAheadMultiRunning: Story = {
value: 3,
max: 5,
state: 'running',
node_id: '2',
node_id: asNodeExecutionId('2'),
prompt_id: 'p2'
}
}

View File

@@ -9,6 +9,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import { asNodeExecutionId } from '@/types/nodeIdentification'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
@@ -398,7 +399,7 @@ describe('TabErrors.vue', () => {
it('shows missing model Refresh in the section header when no model is downloadable', async () => {
const missingModel = {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
@@ -429,7 +430,7 @@ describe('TabErrors.vue', () => {
missingModel: {
missingModelCandidates: [
{
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
@@ -438,7 +439,7 @@ describe('TabErrors.vue', () => {
isAssetSupported: true
},
{
nodeId: '2',
nodeId: asNodeExecutionId('2'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
@@ -460,7 +461,7 @@ describe('TabErrors.vue', () => {
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'local-only.safetensors',
@@ -483,7 +484,7 @@ describe('TabErrors.vue', () => {
it('renders missing media display message below the section title', () => {
const missingMedia = {
nodeId: '3',
nodeId: asNodeExecutionId('3'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -519,7 +520,7 @@ describe('TabErrors.vue', () => {
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeId: asNodeExecutionId('3'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -527,7 +528,7 @@ describe('TabErrors.vue', () => {
isMissing: true
},
{
nodeId: '4',
nodeId: asNodeExecutionId('4'),
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
@@ -595,7 +596,7 @@ describe('TabErrors.vue', () => {
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeId: asNodeExecutionId('3'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -603,7 +604,7 @@ describe('TabErrors.vue', () => {
isMissing: true
},
{
nodeId: '4',
nodeId: asNodeExecutionId('4'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -658,7 +659,7 @@ describe('TabErrors.vue', () => {
it('renders missing model Refresh in the header and Download all in the card when models are downloadable', () => {
const missingModel = {
nodeId: '1',
nodeId: asNodeExecutionId('1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'downloadable.safetensors',

View File

@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
@@ -166,7 +167,7 @@ function makeModel(
) {
return {
name,
nodeId: opts.nodeId ?? '1',
nodeId: asNodeExecutionId(opts.nodeId ?? '1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
@@ -185,7 +186,7 @@ function makeMedia(
): MissingMediaCandidate {
return {
name,
nodeId: opts.nodeId,
nodeId: asNodeExecutionId(opts.nodeId),
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
@@ -540,7 +541,7 @@ describe('useErrorGroups', () => {
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_id: asNodeExecutionId(5),
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
@@ -577,7 +578,7 @@ describe('useErrorGroups', () => {
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_id: asNodeExecutionId(5),
node_type: 'KSampler',
executed: [],
exception_type: 'torch.OutOfMemoryError',

View File

@@ -181,7 +181,7 @@ describe('useErrorReport', () => {
exceptionType: 'RuntimeError',
exceptionMessage: 'CUDA oom',
traceback: 'trace-0',
nodeId: '42',
nodeId: 42,
nodeType: 'KSampler',
systemStats: sampleSystemStats,
serverLogs: 'server logs',

View File

@@ -4,6 +4,7 @@ import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { asNodeId } from '@/types/nodeId'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -81,7 +82,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeId:
card.nodeId === undefined ? undefined : asNodeId(card.nodeId),
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,

View File

@@ -152,7 +152,7 @@ function isWidgetShownOnParents(
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? source.nodeId
: String(widgetNode.id)
: widgetNode.id
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
@@ -160,7 +160,7 @@ function isWidgetShownOnParents(
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceNodeId: widgetNode.id,
sourceWidgetName: widget.name
})
})

View File

@@ -4,7 +4,8 @@ import { computed, reactive, ref, shallowRef, watch } from 'vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'

View File

@@ -3,7 +3,8 @@ import { storeToRefs } from 'pinia'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'

View File

@@ -82,7 +82,7 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceNodeId: interiorNode.id,
sourceWidgetName: getWidgetName(widget)
})
)

View File

@@ -92,7 +92,7 @@ function handleHideInput() {
if (source) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
String(node.id) === String(parent.id) ? source.nodeId : node.id
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName

View File

@@ -5,6 +5,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -145,7 +146,7 @@ describe('WidgetItem', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -160,7 +161,7 @@ describe('WidgetItem', () => {
})
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -175,7 +176,7 @@ describe('WidgetItem', () => {
})
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
@@ -190,7 +191,7 @@ describe('WidgetItem', () => {
})
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const id = widgetId('test-graph-id', asNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',

View File

@@ -70,7 +70,7 @@ const widgetComponent = computed(() => {
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(String(node.id))
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked

View File

@@ -5,7 +5,8 @@ import type { IFuseOptions } from 'fuse.js'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'

View File

@@ -81,7 +81,7 @@ describe('SubgraphEditor', () => {
subgraph.rootGraph.id,
String(host.id),
{
sourceNodeId: String(previewNode.id),
sourceNodeId: previewNode.id,
sourcePreviewName: '$$canvas-image-preview'
}
)
@@ -172,7 +172,7 @@ describe('SubgraphEditor', () => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
return target?.nodeId === sourceNode.id
})!
return {
kind: 'promoted',
@@ -277,7 +277,7 @@ describe('SubgraphEditor', () => {
const previewStore = usePreviewExposureStore()
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
sourceNodeId: String(orphanedSourceNode.id),
sourceNodeId: orphanedSourceNode.id,
sourcePreviewName: '$$canvas-image-preview'
})

View File

@@ -22,8 +22,8 @@ import {
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { asNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
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'
@@ -31,6 +31,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import type { NormalizedPreviewExposure } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -45,7 +46,7 @@ type PromotedRow = {
type PreviewRow = {
kind: 'preview'
node: LGraphNode
exposure: PreviewExposure
exposure: NormalizedPreviewExposure
realWidget?: IBaseWidget
}
type ActiveRow = PromotedRow | PreviewRow
@@ -69,7 +70,7 @@ function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
const sourceNode = node.subgraph._nodes_by_id[asNodeId(source.nodeId)]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
@@ -116,7 +117,7 @@ function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
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]
const sourceNode = node.subgraph.getNodeById(exposure.sourceNodeId)
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
@@ -248,12 +249,12 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
if (row.node.id === UNASSIGNED_NODE_ID) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
isLinkedPromotion(activeNode.value, row.node.id, source.widgetName)
)
}
@@ -321,7 +322,7 @@ function showAll() {
}
function hideAll() {
for (const row of filteredActive.value) {
if (String(row.node.id) === '-1') continue
if (row.node.id === UNASSIGNED_NODE_ID) continue
demoteRow(row)
}
}

View File

@@ -225,6 +225,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
@@ -472,7 +473,7 @@ const galleryItems = computed(() => {
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
nodeId: asNodeExecutionId('0'),
mediaType: mediaType === 'image' ? 'images' : mediaType
})

View File

@@ -4,7 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { ResultItemImpl } from '@/stores/queueStore'
import MediaLightbox from './MediaLightbox.vue'
@@ -28,7 +29,7 @@ type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
nodeId: NodeExecutionId
mediaType: string
id?: string
url?: string
@@ -63,7 +64,7 @@ describe('MediaLightbox', () => {
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
nodeId: asNodeExecutionId('123'),
mediaType: 'images',
isImage: true,
isVideo: false,
@@ -75,7 +76,7 @@ describe('MediaLightbox', () => {
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
nodeId: asNodeExecutionId('456'),
mediaType: 'images',
isImage: true,
isVideo: false,
@@ -87,7 +88,7 @@ describe('MediaLightbox', () => {
filename: 'image3.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '789' as NodeId,
nodeId: asNodeExecutionId('789'),
mediaType: 'images',
isImage: true,
isVideo: false,

View File

@@ -7,7 +7,8 @@ import type { Positionable } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
Reroute
Reroute,
asNodeId
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeId } from '@/renderer/core/layout/types'
@@ -38,7 +39,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
// unmodified — the node accessors filter selectedItems with the real predicate.
const makeNode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.id = asNodeId(id)
node.mode = mode
return node
}
@@ -69,7 +70,7 @@ class MockNode implements Positionable {
) {
this.pos = pos
this.size = size
this.id = 'mock-node'
this.id = asNodeId(100)
this.boundingRect = [0, 0, 0, 0]
}

View File

@@ -100,14 +100,16 @@ export function useSelectionToolboxPosition(
if (item.id == null) continue
if (shouldRenderVueNodes.value && typeof item.id === 'string') {
// Use layout store for Vue nodes (only works with string IDs)
// Use layout store for Vue nodes (only works with string IDs).
// Stored bounds.y excludes the header, so expand upward by the title
// height to reach the visual node top (matching the fallback below).
const layout = layoutStore.getNodeLayoutRef(item.id).value
if (layout) {
allBounds.push([
layout.bounds.x,
layout.bounds.y,
layout.bounds.y - LiteGraph.NODE_TITLE_HEIGHT,
layout.bounds.width,
layout.bounds.height
layout.bounds.height + LiteGraph.NODE_TITLE_HEIGHT
])
}
} else {

View File

@@ -1,6 +1,7 @@
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -21,6 +21,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -50,7 +51,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(store, asNodeExecutionId(node.id), 'clip')
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -62,7 +63,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(store, asNodeExecutionId(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -81,7 +82,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
seedRequiredInputMissingNodeError(store, asNodeExecutionId(node.id), 'clip')
node.onConnectionsChange!(
NodeSlotType.OUTPUT,
@@ -103,7 +104,11 @@ describe('Connection error clearing via onConnectionsChange', () => {
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
seedRequiredInputMissingNodeError(
store,
asNodeExecutionId(node.id),
'model'
)
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
@@ -229,10 +234,14 @@ describe('Widget change error clearing via onWidgetChanged', () => {
const store = useExecutionErrorStore()
const mediaStore = useMissingMediaStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
seedRequiredInputMissingNodeError(
store,
asNodeExecutionId(node.id),
'image'
)
mediaStore.setMissingMedia([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -279,7 +288,11 @@ describe('installErrorClearingHooks lifecycle', () => {
// Verify the hooks actually work
const store = useExecutionErrorStore()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
seedRequiredInputMissingNodeError(
store,
asNodeExecutionId(lateNode.id),
'value'
)
lateNode.onConnectionsChange!(
NodeSlotType.INPUT,
@@ -371,7 +384,7 @@ describe('installErrorClearingHooks lifecycle', () => {
.spyOn(missingModelScan, 'scanNodeModelCandidates')
.mockImplementation((_rootGraph, node) => [
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: node.type,
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -448,7 +461,7 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -471,7 +484,7 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
@@ -481,7 +494,9 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
// graph whose onNodeRemoved fires for interior deletions.
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const interiorExecId = asNodeExecutionId(
`${subgraphNode.id}:${interiorNode.id}`
)
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<
@@ -507,14 +522,16 @@ describe('onNodeRemoved clears missing asset errors by execution ID', () => {
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const interiorExecId = asNodeExecutionId(
`${subgraphNode.id}:${interiorNode.id}`
)
const mediaStore = useMissingMediaStore()
mediaStore.setMissingMedia([
@@ -565,7 +582,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
// verifyAssetSupportedCandidates resolves them against the assets store.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -613,7 +630,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -656,7 +673,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -702,7 +719,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -753,7 +770,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeId: asNodeExecutionId(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
@@ -802,7 +819,7 @@ describe('realtime verification staleness guards', () => {
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(nodeA.id),
nodeId: asNodeExecutionId(nodeA.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
@@ -863,7 +880,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
subgraphNode.mode = LGraphEventMode.BYPASS
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
@@ -873,7 +890,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
// didn't short-circuit first — return a concrete missing candidate.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeId: asNodeExecutionId(`${subgraphNode.id}:${interiorNode.id}`),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -902,13 +919,13 @@ describe('scan skips interior of bypassed subgraph containers', () => {
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph,
id: 76
id: asNodeId(76)
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph,
id: 205
id: asNodeId(205)
})
rootGraph.add(outerSubgraphNode)
@@ -982,11 +999,11 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
it('clears promoted widget errors by interior execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: 2 })
const host = createTestSubgraphNode(subgraph, { id: asNodeId(2) })
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = 1
interiorNode.id = asNodeId(1)
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(
@@ -1007,7 +1024,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: '2:1',
nodeId: asNodeExecutionId('2:1'),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,

View File

@@ -9,6 +9,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import {
LGraphEventMode,
NodeSlotType
@@ -308,7 +309,7 @@ function scheduleAddedNodeScan(node: LGraphNode): void {
function handleNodeModeChange(
localGraph: LGraph,
nodeId: number,
nodeId: NodeId,
oldMode: number,
newMode: number
): void {
@@ -407,7 +408,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
if (event.type === 'node:property:changed' && event.property === 'mode') {
handleNodeModeChange(
graph,
event.nodeId as number,
event.nodeId,
event.oldValue as number,
event.newValue as number
)

View File

@@ -2,9 +2,15 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
BaseWidget,
LGraph,
LGraphNode,
asNodeId
} from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
@@ -116,7 +122,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(asNodeId(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
@@ -127,7 +133,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(asNodeId(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
@@ -138,7 +144,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
nodeId: asNodeId(node.id),
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
@@ -155,7 +161,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const nodeData = vueNodeData.get(asNodeId(node.id))!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
@@ -175,7 +181,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
nodeId: asNodeId(node.id),
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
@@ -204,7 +210,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
throw new Error('Expected SubgraphInput.connect to produce a link')
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(asNodeId(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
@@ -224,13 +230,13 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(123) })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(asNodeId(subgraphNode.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
@@ -242,7 +248,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const nodeData = vueNodeData.get(asNodeId(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
@@ -252,7 +258,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
nodeId: asNodeId(node.id),
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
@@ -278,7 +284,7 @@ describe('Subgraph output slot label reactivity', () => {
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeId = asNodeId(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
@@ -288,7 +294,7 @@ describe('Subgraph output slot label reactivity', () => {
// Simulate what SubgraphNode does: set the label, then fire the trigger
node.outputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
nodeId: asNodeId(node.id),
slotType: NodeSlotType.OUTPUT
})
@@ -306,7 +312,7 @@ describe('Subgraph output slot label reactivity', () => {
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeId = String(node.id)
const nodeId = asNodeId(node.id)
const nodeData = vueNodeData.get(nodeId)
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
@@ -314,7 +320,7 @@ describe('Subgraph output slot label reactivity', () => {
node.inputs[0].label = 'custom_label'
graph.trigger('node:slot-label:changed', {
nodeId: node.id,
nodeId: asNodeId(node.id),
slotType: NodeSlotType.INPUT
})
@@ -330,7 +336,7 @@ describe('Subgraph output slot label reactivity', () => {
expect(() =>
graph.trigger('node:slot-label:changed', {
nodeId: 'missing-node',
nodeId: asNodeId(999_999),
slotType: NodeSlotType.OUTPUT
})
).not.toThrow()
@@ -355,7 +361,9 @@ describe('Nested promoted widget mapping', () => {
subgraphA.add(innerNode)
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
const subgraphNodeA = createTestSubgraphNode(subgraphA, {
id: asNodeId(11)
})
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
@@ -364,12 +372,14 @@ describe('Nested promoted widget mapping', () => {
subgraphNodeA._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
const subgraphNodeB = createTestSubgraphNode(subgraphB, {
id: asNodeId(22)
})
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
const nodeData = vueNodeData.get(asNodeId(subgraphNodeB.id))
const mappedWidget = nodeData?.widgets?.[0]
expect(mappedWidget).toBeDefined()
@@ -401,12 +411,12 @@ describe('Nested promoted widget mapping', () => {
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(100) })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(asNodeId(subgraphNode.id))
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
@@ -444,7 +454,7 @@ describe('Promoted widget sourceExecutionId', () => {
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(65) })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -452,7 +462,7 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const nodeData = vueNodeData.get(asNodeId(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
)
@@ -475,7 +485,7 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const nodeData = vueNodeData.get(asNodeId(node.id))
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
expect(widget).toBeDefined()
@@ -591,7 +601,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
interiorNode.addInput('value', 'INT')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(50) })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -602,7 +612,9 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
const store = useExecutionErrorStore()
// Error on interior node: execution ID = "50:<interiorNodeId>"
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const interiorExecId = asNodeExecutionId(
`${subgraphNode.id}:${interiorNode.id}`
)
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
@@ -632,7 +644,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeId: asNodeExecutionId(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -652,7 +664,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: String(nodeA.id),
nodeId: asNodeExecutionId(nodeA.id),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,
@@ -673,7 +685,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
const interiorNode = new LGraphNode('CheckpointLoader')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(50) })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
@@ -689,7 +701,7 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
missingModelStore.setMissingModels([
{
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
nodeId: asNodeExecutionId(`${subgraphNode.id}:${interiorNode.id}`),
nodeType: 'CheckpointLoader',
widgetName: 'ckpt_name',
isAssetSupported: false,

View File

@@ -134,10 +134,10 @@ export interface VueNodeData {
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
@@ -469,7 +469,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const badges = node.badges
return {
id: String(node.id),
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
@@ -496,12 +496,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: string) => {
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
@@ -516,14 +516,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
@@ -535,7 +535,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = String(node.id)
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
@@ -553,7 +553,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
@@ -615,7 +615,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
@@ -673,7 +673,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const nodeId = propertyEvent.nodeId
const currentData = vueNodeData.get(nodeId)
if (currentData) {
@@ -769,15 +769,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
refreshNodeSlots(slotErrorsEvent.nodeId)
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
refreshNodeSlots(slotLinksEvent.nodeId)
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeId = slotLabelEvent.nodeId
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return

View File

@@ -1,11 +1,8 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type {
LGraphGroup,
LGraphNode,
NodeId
} from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'

View File

@@ -6,7 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
import type { Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
asNodeId
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
// canvasStore transitively imports the app singleton; stub it so the real
@@ -45,7 +49,7 @@ const i18n = createI18n({
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode => {
const node = new LGraphNode('Test')
node.id = id
node.id = asNodeId(id)
node.mode = mode
return node
}

View File

@@ -4,6 +4,7 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { asNodeId } from '@/types/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -29,7 +30,7 @@ function useVueNodeLifecycleIndividual() {
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
id: node.id,
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
}))
@@ -47,9 +48,9 @@ function useVueNodeLifecycleIndividual() {
for (const link of activeGraph._links.values()) {
layoutMutations.createLink(
link.id,
link.origin_id,
asNodeId(link.origin_id),
link.origin_slot,
link.target_id,
asNodeId(link.target_id),
link.target_slot
)
}

View File

@@ -1,9 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -11,6 +11,7 @@ import {
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
@@ -58,7 +59,7 @@ function addInteriorNode(
} = { id: 10 }
): LGraphNode {
const node = new LGraphNode('test')
node.id = options.id
node.id = asNodeId(options.id)
if (options.previewMediaType) {
node.previewMediaType = options.previewMediaType
}
@@ -69,7 +70,7 @@ function addInteriorNode(
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
const store = useNodeOutputStore()
for (const nodeId of nodeIds) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
const locatorId = createNodeLocatorId(subgraphId, asNodeId(nodeId))
store.nodeOutputs[locatorId] = {
images: [{ filename: 'output.png' }]
}
@@ -82,20 +83,20 @@ function seedPreviewImages(
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
const locatorId = createNodeLocatorId(subgraphId, asNodeId(nodeId))
store.nodePreviewImages[locatorId] = urls
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourceNodeId: number | string,
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
{ sourceNodeId: asNodeId(sourceNodeId), sourcePreviewName }
)
}
@@ -153,7 +154,7 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls
@@ -161,6 +162,30 @@ describe(usePromotedPreviews, () => {
])
})
it('types preview exposure source node ids as NodeId', () => {
const setup = createSetup()
const sourceNodeId = asNodeId(10)
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{
sourceNodeId,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
const exposure = usePreviewExposureStore().getExposures(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id)
)[0]
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expectTypeOf(exposure.sourceNodeId).toEqualTypeOf<NodeId>()
type PromotedPreview = (typeof promotedPreviews.value)[number]
expectTypeOf<PromotedPreview['sourceNodeId']>().toEqualTypeOf<NodeId>()
})
it.for([
['video', '/view?filename=output.webm'],
['audio', '/view?filename=output.mp3']
@@ -226,7 +251,7 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
@@ -248,7 +273,7 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
@@ -281,7 +306,9 @@ describe(usePromotedPreviews, () => {
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
const innerHost = createTestSubgraphNode(innerSetup.subgraph, {
id: asNodeId(20)
})
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
@@ -289,7 +316,7 @@ describe(usePromotedPreviews, () => {
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourceNodeId: leafNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
@@ -297,7 +324,7 @@ describe(usePromotedPreviews, () => {
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourceNodeId: innerHost.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
@@ -313,7 +340,7 @@ describe(usePromotedPreviews, () => {
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
@@ -329,10 +356,16 @@ describe(usePromotedPreviews, () => {
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
const innerHost = createTestSubgraphNode(innerSetup.subgraph, {
id: asNodeId(20)
})
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHost = createTestSubgraphNode(outerSetup.subgraph, {
id: asNodeId(11)
})
const secondHost = createTestSubgraphNode(outerSetup.subgraph, {
id: asNodeId(12)
})
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
@@ -342,19 +375,19 @@ describe(usePromotedPreviews, () => {
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
sourceNodeId: String(innerHost.id),
sourceNodeId: innerHost.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourceNodeId: innerHost.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourceNodeId: leafNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourceNodeId: leafNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
@@ -377,7 +410,7 @@ describe(usePromotedPreviews, () => {
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:first']
@@ -388,7 +421,7 @@ describe(usePromotedPreviews, () => {
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceNodeId: asNodeId(10),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:second']

View File

@@ -7,9 +7,10 @@ import type { UUID } from '@/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
interface PromotedPreview {
sourceNodeId: string
sourceNodeId: NodeId
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
@@ -37,7 +38,7 @@ export function usePromotedPreviews(
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafSourceNodeId: NodeId,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
@@ -84,7 +85,7 @@ export function usePromotedPreviews(
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
sourceNodeId: NodeId
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)

View File

@@ -80,7 +80,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const litegraphNode = computed(() => {
if (!nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(nodeId) as LGraphNode | null
return app.canvas.graph.getNodeByRawId(nodeId) as LGraphNode | null
})
function getWidgetByName(name: string): IBaseWidget | undefined {

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { useResultGallery } from '@/composables/queue/useResultGallery'
import type { JobListItem as JobListViewItem } from '@/composables/queue/useJobList'
import { asNodeExecutionId } from '@/types/nodeIdentification'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -14,7 +15,7 @@ const createResultItem = (
filename: url,
subfolder: '',
type: 'output',
nodeId: 'node-1',
nodeId: asNodeExecutionId('node-1'),
mediaType: supportsPreview ? 'images' : 'unknown'
})
// Override url getter for test matching

View File

@@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import WidgetImageCrop from '@/components/imagecrop/WidgetImageCrop.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
createMockLGraphNode,
@@ -83,7 +84,7 @@ const ImageCropHarness = defineComponent({
modelValue,
imageEl,
containerEl,
...useImageCrop(props.nodeId as NodeId, {
...useImageCrop(asNodeId(props.nodeId), {
imageEl,
containerEl,
modelValue
@@ -183,7 +184,7 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
const harnessCleanups: Array<() => void> = []
async function mountHarness(nodeId: NodeId = 2 as NodeId) {
async function mountHarness(nodeId: NodeId = asNodeId(2)) {
const el = document.createElement('div')
document.body.appendChild(el)
const app = createApp(ImageCropHarness, { nodeId: Number(nodeId) })
@@ -657,7 +658,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
nodeId: asNodeId(2),
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {
@@ -689,7 +690,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
nodeId: asNodeId(2),
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
@@ -733,7 +734,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
nodeId: asNodeId(2),
modelValue: { x: 0, y: 0, width: 200, height: 200 }
},
global: {
@@ -779,7 +780,7 @@ describe('WidgetImageCrop', () => {
container: attach,
props: {
widget,
nodeId: 2 as NodeId,
nodeId: asNodeId(2),
modelValue: { x: 0, y: 0, width: 100, height: 100 }
},
global: {

View File

@@ -2,7 +2,8 @@ import { useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import type { Bounds } from '@/renderer/core/layout/types'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { resolveNode } from '@/utils/litegraphUtil'

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asNodeId } from '@/types/nodeId'
import type { WidgetState } from '@/types/widgetState'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
@@ -10,7 +10,7 @@ function widget(name: string, value: unknown): WidgetState {
name,
type: 'INPUT',
value,
nodeId: '1' as NodeId,
nodeId: asNodeId('1'),
options: {},
y: 0
}

View File

@@ -1,5 +1,6 @@
import { computed } from 'vue'
import { asNodeId } from '@/types/nodeId'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
@@ -23,7 +24,10 @@ export function useUpstreamValue<T>(
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
const widgets = widgetValueStore.getNodeWidgets(
graphId,
asNodeId(upstream.nodeId)
)
return extractValue(widgets, upstream.outputName)
})
}

View File

@@ -445,7 +445,7 @@ describe('flushProxyWidgetMigration', () => {
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
expect(exposures[0].sourceNodeId).toBe(String(inner.id))
expect(exposures[0].sourceNodeId).toBe(inner.id)
})
it('classifies type:preview serialize:false widgets as preview exposure', () => {
@@ -465,7 +465,7 @@ describe('flushProxyWidgetMigration', () => {
)
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourceNodeId: inner.id,
sourcePreviewName: 'videopreview'
})
])
@@ -483,7 +483,7 @@ describe('flushProxyWidgetMigration', () => {
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerA.id),
sourceNodeId: innerA.id,
sourcePreviewName: '$$canvas-image-preview'
})
@@ -494,9 +494,7 @@ describe('flushProxyWidgetMigration', () => {
const exposures = store.getExposures(host.rootGraph.id, locator)
expect(exposures).toHaveLength(2)
const newExposure = exposures.find(
(e) => e.sourceNodeId === String(innerB.id)
)
const newExposure = exposures.find((e) => e.sourceNodeId === innerB.id)
expect(newExposure?.name).toBe('$$canvas-image-preview_1')
})
@@ -509,7 +507,7 @@ describe('flushProxyWidgetMigration', () => {
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(inner.id),
sourceNodeId: inner.id,
sourcePreviewName: '$$canvas-image-preview'
})
@@ -746,7 +744,7 @@ describe('flushProxyWidgetMigration', () => {
)
).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourceNodeId: inner.id,
sourcePreviewName: '$$canvas-image-preview'
})
])
@@ -779,7 +777,7 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceNodeId: innerNode.id,
sourceWidgetName: 'seed'
})
})
@@ -795,7 +793,7 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceNodeId: innerNode.id,
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
@@ -833,7 +831,7 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceNodeId: innerNode.id,
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})

View File

@@ -17,7 +17,9 @@ import type {
} 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 { asNodeId } from '@/types/nodeId'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
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'
@@ -30,7 +32,11 @@ import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
interface LegacyProxyEntrySource extends Omit<
PromotedWidgetSource,
'sourceNodeId'
> {
sourceNodeId: NodeId
disambiguatingSourceNodeId?: string
}
@@ -54,7 +60,7 @@ function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
function canResolveLegacyProxy(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceNodeId: NodeId,
widgetName: string
): boolean {
return (
@@ -69,9 +75,12 @@ export function normalizeLegacyProxyWidgetEntry(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): LegacyProxyEntrySource {
if (canResolveLegacyProxy(hostNode, sourceNodeId, sourceWidgetName)) {
const normalizedSourceNodeId = asNodeId(sourceNodeId)
if (
canResolveLegacyProxy(hostNode, normalizedSourceNodeId, sourceWidgetName)
) {
return {
sourceNodeId,
sourceNodeId: normalizedSourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
@@ -82,7 +91,7 @@ export function normalizeLegacyProxyWidgetEntry(
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceNodeId: normalizedSourceNodeId,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
@@ -101,7 +110,7 @@ function resolveSourceWidget(
if (disambiguatingSourceNodeId) {
return (
target?.widgetName === sourceWidgetName &&
target.nodeId === disambiguatingSourceNodeId
String(target.nodeId) === disambiguatingSourceNodeId
)
}
if (input.name === sourceWidgetName) return true
@@ -262,7 +271,7 @@ function collectTargetsStrict(
const link = subgraph.links.get(linkId)
if (!link) return undefined
targets.push({
targetNodeId: link.target_id,
targetNodeId: asNodeId(link.target_id),
targetSlot: link.target_slot
})
}
@@ -278,14 +287,19 @@ function collectTargetsSkippingDangling(
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
? [
{
targetNodeId: asNodeId(link.target_id),
targetSlot: link.target_slot
}
]
: []
})
}
function cohortDuplicatesPrimitive(
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
primitiveNodeId: NodeId
): boolean {
return (
cohort.filter((entry) => entry.sourceNodeId === primitiveNodeId).length >= 2
@@ -306,7 +320,7 @@ function classify(
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
const sourceNode = hostNode.subgraph.getNodeByRawId(normalized.sourceNodeId)
if (!sourceNode) {
return { kind: 'quarantine', reason: 'missingSourceNode' }
}
@@ -428,7 +442,7 @@ function repairCreateSubgraphInput(
sourceWidgetName: string
): RepairValueResult {
const subgraph = hostNode.subgraph
const sourceNode: LGraphNode | null = subgraph.getNodeById(
const sourceNode: LGraphNode | null = subgraph.getNodeByRawId(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
@@ -549,7 +563,7 @@ function rollback(
}
}
for (const link of snapshot) {
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
const targetNode = hostNode.subgraph.getNodeByRawId(link.targetNodeId)
if (!targetNode) continue
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
}
@@ -564,7 +578,7 @@ function repairPrimitive(
return failPrimitive('cohort validation failed', { cohort })
const subgraph = hostNode.subgraph
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
const primitiveNode = subgraph.getNodeByRawId(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)
@@ -579,7 +593,7 @@ function repairPrimitive(
const primitiveOutputType = String(primitiveOutput.type ?? '*')
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
const targetNode = subgraph.getNodeByRawId(target.targetNodeId)
if (!targetNode) return failPrimitive('target node missing', target)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot) return failPrimitive('target slot missing', target)
@@ -603,7 +617,7 @@ function repairPrimitive(
.filter((l): l is NonNullable<typeof l> => l !== undefined)
.map((l) => ({
primitiveSlot: l.origin_slot,
targetNodeId: l.target_id,
targetNodeId: asNodeId(l.target_id),
targetSlot: l.target_slot
}))
@@ -616,7 +630,7 @@ function repairPrimitive(
)
for (const snap of snapshot) {
const targetNode = subgraph.getNodeById(snap.targetNodeId)
const targetNode = subgraph.getNodeByRawId(snap.targetNodeId)
if (!targetNode)
throw new Error(
`target node ${snap.targetNodeId} disappeared mid-mutation`
@@ -625,7 +639,7 @@ function repairPrimitive(
}
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
const targetNode = subgraph.getNodeByRawId(target.targetNodeId)
if (!targetNode)
throw new Error(`target node ${target.targetNodeId} disappeared`)
const targetSlot = targetNode.inputs?.[target.targetSlot]
@@ -683,7 +697,7 @@ function migratePreview(
store: ReturnType<typeof usePreviewExposureStore>,
plan: { kind: 'previewExposure'; sourcePreviewName: string }
): MigratePreviewResult {
const sourceNode = hostNode.subgraph.getNodeById(
const sourceNode = hostNode.subgraph.getNodeByRawId(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
@@ -725,8 +739,8 @@ function quarantineFor(
const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } =
entry.normalized
const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId
? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId]
: [sourceNodeId, sourceWidgetName]
? [String(sourceNodeId), sourceWidgetName, disambiguatingSourceNodeId]
: [String(sourceNodeId), sourceWidgetName]
return makeQuarantineEntry({
originalEntry,
reason,

View File

@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import { asNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
@@ -9,11 +11,13 @@ import { resolvePreviewExposureChain } from './previewExposureChain'
const rootGraphA = 'root-a' as UUID
const rootGraphB = 'root-b' as UUID
interface FixtureExposure extends PreviewExposure {}
type FixtureExposure = Omit<PreviewExposure, 'sourceNodeId'> & {
sourceNodeId: NodeId
}
interface NestedHostMapping {
fromHostLocator: string
fromSourceNodeId: string
fromSourceNodeId: NodeId
toRootGraphId: UUID
toHostLocator: string
}
@@ -66,7 +70,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'preview',
sourceNodeId: '42',
sourceNodeId: asNodeId(42),
sourcePreviewName: '$$canvas-image-preview'
}
]
@@ -88,14 +92,14 @@ describe(resolvePreviewExposureChain, () => {
hostNodeLocator: 'host-a',
exposure: {
name: 'preview',
sourceNodeId: '42',
sourceNodeId: asNodeId(42),
sourcePreviewName: '$$canvas-image-preview'
}
}
],
leaf: {
rootGraphId: rootGraphA,
sourceNodeId: '42',
sourceNodeId: asNodeId(42),
sourcePreviewName: '$$canvas-image-preview'
}
})
@@ -108,7 +112,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'outer-preview',
sourceNodeId: '99',
sourceNodeId: asNodeId(99),
sourcePreviewName: 'inner-preview'
}
]
@@ -118,7 +122,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'inner-preview',
sourceNodeId: 'leaf-node',
sourceNodeId: asNodeId(77),
sourcePreviewName: '$$canvas-image-preview'
}
]
@@ -127,7 +131,7 @@ describe(resolvePreviewExposureChain, () => {
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
fromSourceNodeId: asNodeId(99),
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
@@ -145,7 +149,7 @@ describe(resolvePreviewExposureChain, () => {
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'leaf-node',
sourceNodeId: asNodeId(77),
sourcePreviewName: '$$canvas-image-preview'
})
})
@@ -157,7 +161,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'p1',
sourceNodeId: 'sub-a',
sourceNodeId: asNodeId(10),
sourcePreviewName: 'p2'
}
]
@@ -167,7 +171,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'p2',
sourceNodeId: 'sub-b',
sourceNodeId: asNodeId(20),
sourcePreviewName: 'p3'
}
]
@@ -177,7 +181,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'p3',
sourceNodeId: 'leaf',
sourceNodeId: asNodeId(30),
sourcePreviewName: '$$canvas-image-preview'
}
]
@@ -186,13 +190,13 @@ describe(resolvePreviewExposureChain, () => {
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-1',
fromSourceNodeId: 'sub-a',
fromSourceNodeId: asNodeId(10),
toRootGraphId: rootGraphA,
toHostLocator: 'host-2'
},
{
fromHostLocator: 'host-2',
fromSourceNodeId: 'sub-b',
fromSourceNodeId: asNodeId(20),
toRootGraphId: rootGraphB,
toHostLocator: 'host-3'
}
@@ -208,7 +212,7 @@ describe(resolvePreviewExposureChain, () => {
])
expect(result?.leaf).toEqual({
rootGraphId: rootGraphB,
sourceNodeId: 'leaf',
sourceNodeId: asNodeId(30),
sourcePreviewName: '$$canvas-image-preview'
})
})
@@ -220,7 +224,7 @@ describe(resolvePreviewExposureChain, () => {
[
{
name: 'outer',
sourceNodeId: '99',
sourceNodeId: asNodeId(99),
sourcePreviewName: 'missing-on-inner'
}
]
@@ -230,7 +234,7 @@ describe(resolvePreviewExposureChain, () => {
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
fromSourceNodeId: asNodeId(99),
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
@@ -246,7 +250,7 @@ describe(resolvePreviewExposureChain, () => {
expect(result?.steps).toHaveLength(1)
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: '99',
sourceNodeId: asNodeId(99),
sourcePreviewName: 'missing-on-inner'
})
})
@@ -255,13 +259,19 @@ describe(resolvePreviewExposureChain, () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
[
{
name: 'cyclic',
sourceNodeId: asNodeId(40),
sourcePreviewName: 'cyclic'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-a',
fromSourceNodeId: 'sub',
fromSourceNodeId: asNodeId(40),
toRootGraphId: rootGraphA,
toHostLocator: 'host-a'
}
@@ -278,6 +288,6 @@ describe(resolvePreviewExposureChain, () => {
expect.stringContaining('cycle detected')
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf.sourceNodeId).toBe('sub')
expect(result?.leaf.sourceNodeId).toBe(asNodeId(40))
})
})

View File

@@ -1,17 +1,22 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { NodeId } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
type ChainPreviewExposure = Omit<PreviewExposure, 'sourceNodeId'> & {
sourceNodeId: NodeId
}
interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure
exposure: ChainPreviewExposure
}
export interface ResolvedPreviewChain {
steps: readonly ResolvedPreviewChainStep[]
leaf: {
rootGraphId: UUID
sourceNodeId: string
sourceNodeId: NodeId
sourcePreviewName: string
}
}
@@ -20,11 +25,11 @@ export interface PreviewExposureChainContext {
getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[]
): readonly ChainPreviewExposure[]
resolveNestedHost(
rootGraphId: UUID,
hostNodeLocator: string,
sourceNodeId: string
sourceNodeId: NodeId
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
}

View File

@@ -3,6 +3,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { NodeId } from '@/types/nodeId'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
@@ -13,7 +14,7 @@ import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
nodeId: NodeId
widgetName: string
}

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/types/nodeId'
export interface ResolvedPromotedWidget {
node: LGraphNode
@@ -12,6 +13,6 @@ export interface ResolvedPromotedWidget {
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceNodeId: NodeId
sourceWidgetName: string
}

View File

@@ -13,6 +13,7 @@ import {
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asNodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
function promotedInputNames(host: {
@@ -223,7 +224,7 @@ describe('pruneDisconnected', () => {
subgraphNode.rootGraph.id,
hostLocator,
{
sourceNodeId: String(interiorNode.id),
sourceNodeId: interiorNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
@@ -238,7 +239,7 @@ describe('pruneDisconnected', () => {
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourceNodeId: interiorNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -363,7 +364,7 @@ describe('promoteRecommendedWidgets', () => {
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourceNodeId: glslNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -408,7 +409,7 @@ describe('promoteRecommendedWidgets', () => {
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourceNodeId: glslNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
@@ -468,7 +469,7 @@ describe('autoExposeKnownPreviewNodes', () => {
subgraphNode.properties.previewExposures = [
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(otherNode.id),
sourceNodeId: otherNode.id,
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
]
@@ -479,7 +480,7 @@ describe('autoExposeKnownPreviewNodes', () => {
usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
).not.toContain(glslNode.id)
})
})
@@ -543,20 +544,20 @@ describe('isLinkedPromotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
expect(isLinkedPromotion(host, node.id, 'text')).toBe(true)
})
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
expect(isLinkedPromotion(host, asNodeId(999), 'nonexistent')).toBe(false)
})
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
expect(isLinkedPromotion(host, node.id, 'wrong_name')).toBe(false)
})
it('identifies linked widgets across different inputs', () => {
@@ -564,10 +565,10 @@ describe('isLinkedPromotion', () => {
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
expect(isLinkedPromotion(host, nodeA.id, 'string_a')).toBe(true)
expect(isLinkedPromotion(host, nodeB.id, 'value')).toBe(true)
expect(isLinkedPromotion(host, nodeA.id, 'value')).toBe(false)
expect(isLinkedPromotion(host, asNodeId(5), 'string_a')).toBe(false)
})
})
@@ -795,9 +796,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
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(isLinkedPromotion(host, interiorNode.id, interiorWidget.name)).toBe(
false
)
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
@@ -821,8 +822,8 @@ describe('demoteWidget — axiomatic projection retraction', () => {
demoteWidget(nodeB, widgetB, [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)
expect(isLinkedPromotion(host, nodeB.id, widgetB.name)).toBe(false)
expect(isLinkedPromotion(host, nodeA.id, widgetA.name)).toBe(true)
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
@@ -849,12 +850,8 @@ describe('demoteWidget — axiomatic projection retraction', () => {
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [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
)
expect(isLinkedPromotion(outerHost, innerHost.id, 'text_1')).toBe(false)
expect(isLinkedPromotion(outerHost, innerHost.id, 'text')).toBe(true)
})
})

View File

@@ -21,6 +21,7 @@ import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { NodeId } from '@/types/nodeId'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -33,7 +34,7 @@ export function getWidgetName(w: IBaseWidget): string {
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceNodeId: NodeId,
sourceWidgetName: string
): boolean {
return (
@@ -44,7 +45,7 @@ export function isLinkedPromotion(
export function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceNodeId: NodeId,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
@@ -74,7 +75,7 @@ function resolvePromotionSource(
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceNodeId: inputNode.id,
sourceWidgetName: targetInput.name
}
}
@@ -83,7 +84,7 @@ function resolvePromotionSource(
if (!targetWidget) continue
return {
sourceNodeId: String(inputNode.id),
sourceNodeId: inputNode.id,
sourceWidgetName: targetWidget.name
}
}
@@ -212,7 +213,7 @@ function toPromotionSource(
widget: IBaseWidget
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceNodeId: node.id,
sourceWidgetName: getWidgetName(widget)
}
}
@@ -235,9 +236,7 @@ export function promoteValueWidgetViaSubgraphInput(
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
if (isLinkedPromotion(subgraphNode, sourceNode.id, sourceWidgetName)) {
return { ok: true }
}
@@ -309,13 +308,13 @@ function promotePreviewViaExposure(
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourceNodeId === sourceNode.id &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourceNodeId: sourceNode.id,
sourcePreviewName
})
}
@@ -603,7 +602,7 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
if (!hostInput?.widgetId && !hostInput?._widget) return false
removedEntries.push({
sourceNodeId: String(subgraphNode.id),
sourceNodeId: subgraphNode.id,
sourceWidgetName: input.name
})
return true
@@ -642,7 +641,7 @@ export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
!isWidgetPromotedOnSubgraphNode(
subgraphNode,
{
sourceNodeId: String(interiorNode.id),
sourceNodeId: interiorNode.id,
sourceWidgetName: widget.name
},
widget

View File

@@ -4,7 +4,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
@@ -23,7 +23,7 @@ vi.mock('@/services/litegraphService', () => ({
}))
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
return createTestSubgraphNode(createTestSubgraph(), { id: asNodeId(id) })
}
function addNodeToHost(host: SubgraphNode, title: string): LGraphNode {
@@ -46,11 +46,7 @@ describe('resolveConcretePromotedWidget', () => {
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const result = resolveConcretePromotedWidget(
host,
String(concreteNode.id),
'seed'
)
const result = resolveConcretePromotedWidget(host, concreteNode.id, 'seed')
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
@@ -71,7 +67,9 @@ describe('resolveConcretePromotedWidget', () => {
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
const innerNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(11)
})
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'y', type: '*' }]
@@ -80,13 +78,11 @@ describe('resolveConcretePromotedWidget', () => {
innerNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
const outerNode = createTestSubgraphNode(outerSubgraph, {
id: asNodeId(22)
})
const result = resolveConcretePromotedWidget(
outerNode,
String(innerNode.id),
'x'
)
const result = resolveConcretePromotedWidget(outerNode, innerNode.id, 'x')
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
@@ -98,7 +94,7 @@ describe('resolveConcretePromotedWidget', () => {
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
const recursiveInput = { name: 'x', link: 1 }
const recursiveNode = fromAny<LGraphNode, unknown>({
id: 11,
id: asNodeId(11),
inputs: [recursiveInput],
isSubgraphNode: () => true,
subgraph: {
@@ -106,17 +102,17 @@ describe('resolveConcretePromotedWidget', () => {
getLink: () => ({
resolve: () => ({ inputNode: recursiveNode })
}),
getNodeById: () => recursiveNode
getNodeByRawId: () => recursiveNode
}
})
const host = fromAny<SubgraphNode, unknown>({
isSubgraphNode: () => true,
subgraph: {
getNodeById: () => recursiveNode
getNodeByRawId: () => recursiveNode
}
})
const result = resolveConcretePromotedWidget(host, '11', 'x')
const result = resolveConcretePromotedWidget(host, asNodeId(11), 'x')
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
})
@@ -129,15 +125,15 @@ describe('resolveConcretePromotedWidget', () => {
for (let index = 0; index < subgraphs.length - 1; index++) {
const current = subgraphs[index]
const next = subgraphs[index + 1]
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
const nextNode = createTestSubgraphNode(next, { id: asNodeId(index + 1) })
current.add(nextNode)
nextNode._internalConfigureAfterSlots()
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
}
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
const host = createTestSubgraphNode(subgraphs[0], { id: asNodeId(200) })
const result = resolveConcretePromotedWidget(host, '1', 'x')
const result = resolveConcretePromotedWidget(host, asNodeId(1), 'x')
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'
@@ -147,7 +143,7 @@ describe('resolveConcretePromotedWidget', () => {
test('returns invalid-host for non-subgraph host node', () => {
const host = new LGraphNode('plain-host')
const result = resolveConcretePromotedWidget(host, 'x', 'y')
const result = resolveConcretePromotedWidget(host, asNodeId(1), 'y')
expect(result).toEqual({
status: 'failure',
@@ -158,7 +154,7 @@ describe('resolveConcretePromotedWidget', () => {
test('returns missing-node when source node does not exist in host subgraph', () => {
const host = createHostNode(100)
const result = resolveConcretePromotedWidget(host, 'missing-node', 'seed')
const result = resolveConcretePromotedWidget(host, asNodeId(999), 'seed')
expect(result).toEqual({
status: 'failure',
@@ -173,7 +169,7 @@ describe('resolveConcretePromotedWidget', () => {
const result = resolveConcretePromotedWidget(
host,
String(sourceNode.id),
sourceNode.id,
'missing-widget'
)

View File

@@ -2,6 +2,7 @@ import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidge
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { NodeId } from '@/types/nodeId'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -18,7 +19,7 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
nodeId: NodeId,
widgetName: string
): PromotedWidgetResolutionResult {
const visitedByHost = new WeakMap<SubgraphNode, Set<string>>()
@@ -35,7 +36,7 @@ function traversePromotedWidgetChain(
visited.add(key)
visitedByHost.set(currentHost, visited)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
const sourceNode = currentHost.subgraph.getNodeByRawId(currentNodeId)
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
@@ -69,7 +70,7 @@ function traversePromotedWidgetChain(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
nodeId: NodeId,
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {

View File

@@ -4,7 +4,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
@@ -30,7 +30,7 @@ function createSubgraphSetup(inputName: string): {
const subgraph = createTestSubgraph({
inputs: [{ name: inputName, type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: asNodeId(1) })
return { subgraph, subgraphNode }
}

View File

@@ -3,7 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, asNodeId } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -28,7 +28,9 @@ function createOuterSubgraphSetup(inputNames: string[]): {
const outerSubgraph = createTestSubgraph({
inputs: inputNames.map((name) => ({ name, type: '*' }))
})
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
id: asNodeId(1)
})
return { outerSubgraph, outerSubgraphNode }
}
@@ -41,7 +43,9 @@ function addLinkedNestedSubgraphNode(
const innerSubgraph = createTestSubgraph({
inputs: [{ name: linkedInputName, type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: asNodeId(819)
})
outerSubgraph.add(innerSubgraphNode)
const inputSlot = outerSubgraph.inputNode.slots.find(
@@ -139,7 +143,7 @@ describe('resolveSubgraphInputTarget', () => {
(slot) => slot.name === 'seed'
)!
const node = new LGraphNode('Interior-seed')
node.id = 42
node.id = asNodeId(42)
const input = node.addInput('seed_input', '*')
node.addWidget('number', 'seed', 0, () => undefined)
input.widget = { name: 'seed' }
@@ -185,7 +189,7 @@ describe('resolveSubgraphInputTarget', () => {
]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 820
id: asNodeId(820)
})
outerSubgraph.add(innerSubgraphNode)
@@ -224,7 +228,7 @@ describe('resolveSubgraphInputTarget', () => {
inputs: [{ name: 'seed', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
concreteNode.id = 900
concreteNode.id = asNodeId(900)
const concreteInput = concreteNode.addInput('seed_input', '*')
concreteNode.addWidget('number', 'seed', 0, () => undefined)
concreteInput.widget = { name: 'seed' }
@@ -235,7 +239,7 @@ describe('resolveSubgraphInputTarget', () => {
inputs: [{ name: 'seed', type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 901
id: asNodeId(901)
})
middleSubgraph.add(innerSubgraphNode)
const middleInput = innerSubgraphNode.addInput('seed', '*')
@@ -247,7 +251,7 @@ describe('resolveSubgraphInputTarget', () => {
'seed'
])
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
id: 902
id: asNodeId(902)
})
outerSubgraph.add(middleSubgraphNode)
const outerInput = middleSubgraphNode.addInput('seed', '*')

View File

@@ -1,9 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/types/nodeId'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
nodeId: NodeId
widgetName: string
}
@@ -17,7 +18,7 @@ export function resolveSubgraphInputTarget(
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
return {
nodeId: String(inputNode.id),
nodeId: inputNode.id,
widgetName: targetInput.name
}
}
@@ -26,7 +27,7 @@ export function resolveSubgraphInputTarget(
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
nodeId: inputNode.id,
widgetName: targetWidget.name
}
}

View File

@@ -16,7 +16,18 @@ describe(parsePreviewExposures, () => {
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(input)).toEqual(input)
expect(parsePreviewExposures(input)).toEqual([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'preview2',
sourceNodeId: 7,
sourcePreviewName: '$$canvas-image-preview'
}
])
})
it('parses JSON-string input', () => {
@@ -27,7 +38,13 @@ describe(parsePreviewExposures, () => {
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
expect(parsePreviewExposures(JSON.stringify(input))).toEqual([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
}
])
})
it('returns empty array for undefined', () => {
@@ -65,7 +82,7 @@ describe(parsePreviewExposures, () => {
).toEqual([])
})
it('returns empty array when entries have wrong types', () => {
it('returns empty array when entries have wrong types or invalid node ids', () => {
expect(
parsePreviewExposures([
{
@@ -79,7 +96,7 @@ describe(parsePreviewExposures, () => {
parsePreviewExposures([
{
name: 'preview',
sourceNodeId: 5,
sourceNodeId: 'not-a-node-id',
sourcePreviewName: '$$canvas-image-preview'
}
])

View File

@@ -1,12 +1,16 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { tryAsNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourceNodeId: z
.union([z.number(), z.string()])
.transform(tryAsNodeId)
.refine((value): value is NodeId => value !== null, 'Invalid NodeId'),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
@@ -16,9 +20,20 @@ const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
return parseNodePropertyArray(
property,
previewExposuresPropertySchema,
'properties.previewExposures'
)
if (property === undefined) return []
let parsed: unknown
try {
parsed = typeof property === 'string' ? JSON.parse(property) : property
} catch (e) {
console.warn('Failed to parse properties.previewExposures:', e)
return []
}
const result = previewExposuresPropertySchema.safeParse(parsed)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.previewExposures:\n${error}`)
return []
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asNodeId } from '@/types/nodeId'
import type { GroupNodeWorkflowData } from './groupNode'
@@ -15,7 +16,7 @@ import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
function makeNode(type: string): ComfyNode {
return {
id: 1,
id: asNodeId(1),
type,
pos: [0, 0],
size: [1, 1],
@@ -66,8 +67,22 @@ describe('GroupNodeConfig.getLinks', () => {
}
it('indexes outgoing links by [origin index][origin slot]', () => {
const clip = [1, 1, 2, 0, 4, 'CLIP'] satisfies SerialisedLLinkArray
const model = [1, 0, 4, 0, 4, 'MODEL'] satisfies SerialisedLLinkArray
const clip = [
1,
asNodeId(1),
2,
asNodeId(0),
4,
'CLIP'
] satisfies SerialisedLLinkArray
const model = [
1,
asNodeId(0),
4,
asNodeId(0),
4,
'MODEL'
] satisfies SerialisedLLinkArray
const config = configFrom([clip, model])
expect(config.linksFrom[1][1]).toEqual([clip])
@@ -75,8 +90,22 @@ describe('GroupNodeConfig.getLinks', () => {
})
it('indexes incoming links by [target index][target slot]', () => {
const clip = [1, 1, 2, 0, 4, 'CLIP'] satisfies SerialisedLLinkArray
const cond = [2, 0, 4, 1, 6, 'CONDITIONING'] satisfies SerialisedLLinkArray
const clip = [
1,
asNodeId(1),
2,
asNodeId(0),
4,
'CLIP'
] satisfies SerialisedLLinkArray
const cond = [
2,
asNodeId(0),
4,
asNodeId(1),
6,
'CONDITIONING'
] satisfies SerialisedLLinkArray
const config = configFrom([clip, cond])
expect(config.linksTo[2][0]).toEqual(clip)
@@ -84,15 +113,36 @@ describe('GroupNodeConfig.getLinks', () => {
})
it('accumulates multiple fan-out links from the same origin slot', () => {
const toPos = [1, 1, 2, 0, 4, 'CLIP'] satisfies SerialisedLLinkArray
const toNeg = [1, 1, 3, 0, 5, 'CLIP'] satisfies SerialisedLLinkArray
const toPos = [
1,
asNodeId(1),
2,
asNodeId(0),
4,
'CLIP'
] satisfies SerialisedLLinkArray
const toNeg = [
1,
asNodeId(1),
3,
asNodeId(0),
5,
'CLIP'
] satisfies SerialisedLLinkArray
const config = configFrom([toPos, toNeg])
expect(config.linksFrom[1][1]).toEqual([toPos, toNeg])
})
it('skips links that have a null endpoint', () => {
const valid = [1, 1, 2, 0, 4, 'CLIP'] satisfies SerialisedLLinkArray
const valid = [
1,
asNodeId(1),
2,
asNodeId(0),
4,
'CLIP'
] satisfies SerialisedLLinkArray
const broken = [null, 1, 2, 0, 4, 'CLIP'] as unknown as SerialisedLLinkArray
const config = configFrom([valid, broken])

View File

@@ -851,7 +851,7 @@ class GroupNodeHandler {
const selectedIds = Object.keys(app.canvas.selected_nodes)
const newNodes: LGraphNode[] = []
for (let i = 0; i < selectedIds.length; i++) {
const newNode = app.rootGraph.getNodeById(selectedIds[i])
const newNode = app.rootGraph.getNodeByRawId(selectedIds[i])
const innerNodeData = nodeData.nodes[i]
if (!newNode) continue
newNodes.push(newNode)
@@ -905,7 +905,7 @@ class GroupNodeHandler {
const reconnectInputs = (selectedIds: (string | number)[]) => {
for (const innerNodeIndex in oldToNewInputMap) {
const newNode = app.rootGraph.getNodeById(
const newNode = app.rootGraph.getNodeByRawId(
selectedIds[Number(innerNodeIndex)]
)
if (!newNode) continue
@@ -917,7 +917,7 @@ class GroupNodeHandler {
if (slot.link == null) continue
const link = app.rootGraph.links[slot.link]
if (!link) continue
const originNode = app.rootGraph.getNodeById(link.origin_id)
const originNode = app.rootGraph.getNodeByRawId(link.origin_id)
originNode?.connect(link.origin_slot, newNode, +innerInputId)
}
}
@@ -937,8 +937,8 @@ class GroupNodeHandler {
if (!slot) continue
const link = app.rootGraph.links[l]
if (!link) continue
const targetNode = app.rootGraph.getNodeById(link.target_id)
const newNode = app.rootGraph.getNodeById(
const targetNode = app.rootGraph.getNodeByRawId(link.target_id)
const newNode = app.rootGraph.getNodeByRawId(
selectedIds[slot.node.index ?? 0]
)
if (targetNode) {

View File

@@ -6,6 +6,7 @@ import type {
INodeOutputSlot,
LLink
} from '@/lib/litegraph/src/litegraph'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
@@ -39,7 +40,7 @@ function createTargetNode(
id = 7
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
id,
id: asNodeId(id),
inputs: [
fromPartial<INodeInputSlot>({
widget: { name: widget.name }

View File

@@ -2,8 +2,9 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
asNodeId,
LGraph,
LGraphNode,
LiteGraph,
@@ -142,6 +143,25 @@ describe('LGraph', () => {
})
})
describe('NodeId lookup typing', () => {
it('requires branded NodeId for runtime node lookup', () => {
const graph = new LGraph()
const node = new LGraphNode('Test')
graph.add(node)
expect(graph.getNodeById(node.id)).toBe(node)
expect(graph.getNodeById(asNodeId(node.id))).toBe(node)
expect(graph.getNodeById(asNodeId(999))).toBeNull()
expect(graph.getNodeByRawId(999)).toBeNull()
// @ts-expect-error raw numeric ids must be normalized at boundaries first
graph.getNodeById(1)
// @ts-expect-error raw string ids must be normalized at boundaries first
graph.getNodeById('1')
})
})
describe('Floating Links / Reroutes', () => {
test('Floating reroute should be removed when node and link are removed', ({
expect,
@@ -297,7 +317,7 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
const seedWidgetId = widgetId(graphId, asNodeId('10'), 'seed')
widgetValueStore.registerWidget(seedWidgetId, {
type: 'number',
value: 1,
@@ -461,6 +481,47 @@ describe('Shared LGraphState', () => {
})
})
describe('configure with arbitrary string node ids', () => {
class StringIdNode extends LGraphNode {
constructor() {
super('StringIdNode')
this.addOutput('out', 'STRING')
this.addInput('in', 'STRING')
}
}
beforeEach(() => {
LiteGraph.registerNodeType('test/string-id', StringIdNode)
})
it('remaps string ids to numeric and preserves link connectivity', () => {
const graph = new LGraph()
graph.configure({
id: zeroUuid,
revision: 0,
version: 0.4,
config: {},
last_node_id: 0,
last_link_id: 18,
groups: [],
nodes: [
{ id: 'Source.0', type: 'test/string-id' },
{ id: 'Sink.0', type: 'test/string-id' }
],
links: [[10, 'Source.0', 0, 'Sink.0', 0, 'STRING']]
} as unknown as SerialisableGraph)
expect(graph._nodes).toHaveLength(2)
expect(graph._nodes.every((n) => typeof n.id === 'number')).toBe(true)
const [source, sink] = graph._nodes
const link = [...graph._links.values()][0]
expect(link.origin_id).toBe(source.id)
expect(link.target_id).toBe(sink.id)
expect(graph.getNodeById(source.id)).toBe(source)
})
})
describe('ensureGlobalIdUniqueness', () => {
function createSubgraphOnGraph(rootGraph: LGraph): Subgraph {
const data = createTestSubgraphData()
@@ -483,7 +544,7 @@ describe('ensureGlobalIdUniqueness', () => {
expect(subNode.id).not.toBe(rootNode.id)
expect(subgraph._nodes_by_id[subNode.id]).toBe(subNode)
expect(subgraph._nodes_by_id[rootNode.id as number]).toBeUndefined()
expect(subgraph._nodes_by_id[rootNode.id]).toBeUndefined()
})
it('preserves root graph node IDs as canonical', () => {
@@ -519,7 +580,7 @@ describe('ensureGlobalIdUniqueness', () => {
rootGraph.ensureGlobalIdUniqueness()
expect(rootGraph.state.lastNodeId).toBeGreaterThanOrEqual(
subNode.id as number
Number(subNode.id)
)
})
@@ -536,7 +597,7 @@ describe('ensureGlobalIdUniqueness', () => {
subgraph._nodes_by_id[subNodeA.id] = subNodeA
const subNodeB = new DummyNode()
subNodeB.id = 999
subNodeB.id = asNodeId(999)
subgraph._nodes.push(subNodeB)
subgraph._nodes_by_id[subNodeB.id] = subNodeB
@@ -555,13 +616,14 @@ describe('ensureGlobalIdUniqueness', () => {
const subgraph = createSubgraphOnGraph(rootGraph)
const subNode = new DummyNode()
subNode.id = 42
subNode.id = asNodeId(42)
subgraph._nodes.push(subNode)
subgraph._nodes_by_id[subNode.id] = subNode
rootGraph.ensureGlobalIdUniqueness([42])
expect(subNode.id).not.toBe(42)
expect(subNode.id).not.toBe(asNodeId(42))
expect(subgraph._nodes_by_id[asNodeId(42)]).toBeUndefined()
expect(subgraph._nodes_by_id[subNode.id]).toBe(subNode)
})
@@ -867,7 +929,7 @@ describe('Subgraph Unpacking', () => {
const firstInstance = createTestSubgraphNode(subgraph, { pos: [100, 100] })
const secondInstance = createTestSubgraphNode(subgraph, { pos: [300, 100] })
secondInstance.id = 2
secondInstance.id = asNodeId(2)
rootGraph.add(firstInstance)
rootGraph.add(secondInstance)
@@ -915,7 +977,7 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const id of SHARED_NODE_IDS) {
expect(idsA.has(id as NodeId)).toBe(true)
expect(idsA.has(asNodeId(id))).toBe(true)
}
for (const id of idsA) {
expect(idsB.has(id)).toBe(false)
@@ -927,8 +989,8 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) {
expect(idsB.has(link.origin_id)).toBe(true)
expect(idsB.has(link.target_id)).toBe(true)
expect(idsB.has(asNodeId(link.origin_id))).toBe(true)
expect(idsB.has(asNodeId(link.target_id))).toBe(true)
}
})
@@ -951,14 +1013,14 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
)
const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets
const pw102 = graph.getNodeById(asNodeId(102))?.properties?.proxyWidgets
expect(Array.isArray(pw102)).toBe(true)
for (const entry of pw102 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsA.has(String(entry[0]))).toBe(true)
}
const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets
const pw103 = graph.getNodeById(asNodeId(103))?.properties?.proxyWidgets
expect(Array.isArray(pw103)).toBe(true)
for (const entry of pw103 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
@@ -976,7 +1038,7 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const innerNode = graph.subgraphs
.get(SUBGRAPH_A)!
.nodes.find((n) => n.id === (50 as NodeId))
.nodes.find((n) => n.id === asNodeId(50))
const pw = innerNode?.properties?.proxyWidgets
expect(Array.isArray(pw)).toBe(true)
for (const entry of pw as unknown[][]) {
@@ -1031,15 +1093,19 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
expect(() => {
const graph = new LGraph()
graph.configure(structuredClone(nodeIdSpaceExhausted))
}).toThrow('Node ID space exhausted')
}).toThrow('LiteGraph: node ID space exhausted')
})
it('is a no-op when subgraph node IDs are already unique', () => {
const graph = new LGraph()
graph.configure(structuredClone(uniqueSubgraphNodeIds))
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12]))
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(
new Set([asNodeId(10), asNodeId(11), asNodeId(12)])
)
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(
new Set([asNodeId(20), asNodeId(21), asNodeId(22)])
)
})
})

View File

@@ -1,9 +1,5 @@
import { toString } from 'es-toolkit/compat'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
@@ -19,6 +15,8 @@ import {
repairInputLinks,
selectSurvivorLink
} from './linkDeduplication'
import { nextFreeNodeId } from './nodeIdAllocation'
import { normalizeStringNodeIds } from './nodeIdNormalization'
import type { DragAndScaleState } from './DragAndScale'
import { LGraphCanvas } from './LGraphCanvas'
@@ -87,6 +85,18 @@ import type {
SerialisableReroute
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
import {
SUBGRAPH_INPUT_NODE_ID,
SUBGRAPH_OUTPUT_NODE_ID,
asNodeId,
isFloatingNodeId,
isNumericNodeId,
isSubgraphInputNodeId,
isSubgraphOutputNodeId,
isUnassignedNodeId,
nodeIdToNumber
} from '@/types/nodeId'
import type { NodeIdInput } from '@/types/nodeId'
import {
deduplicateSubgraphNodeIds,
topologicalSortSubgraphs
@@ -632,7 +642,7 @@ export class LGraph
const S: LGraphNode[] = []
const M: Dictionary<LGraphNode> = {}
// to avoid repeating links
const visited_links: Record<NodeId, boolean> = {}
const visited_links: Record<LinkId, boolean> = {}
const remaining_links: Record<NodeId, number> = {}
// search for the nodes without inputs (starting nodes)
@@ -949,11 +959,11 @@ export class LGraph
}
// nodes
if (node.id != -1 && this._nodes_by_id[node.id] != null) {
if (!isUnassignedNodeId(node.id) && this._nodes_by_id[node.id] != null) {
console.warn(
'LiteGraph: there is already a node with this ID, changing it'
)
node.id = ++state.lastNodeId
node.id = asNodeId(++state.lastNodeId)
}
if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) {
@@ -961,10 +971,13 @@ export class LGraph
}
// give him an id
if (node.id == null || node.id == -1) {
node.id = ++state.lastNodeId
} else if (typeof node.id === 'number' && state.lastNodeId < node.id) {
state.lastNodeId = node.id
if (isUnassignedNodeId(node.id)) {
node.id = asNodeId(++state.lastNodeId)
} else if (
isNumericNodeId(node.id) &&
state.lastNodeId < nodeIdToNumber(node.id)
) {
state.lastNodeId = nodeIdToNumber(node.id)
}
// Set ghost flag before registration so VueNodeData picks it up
@@ -1129,7 +1142,11 @@ export class LGraph
* Returns a node by its id.
*/
getNodeById(id: NodeId | null | undefined): LGraphNode | null {
return id != null ? this._nodes_by_id[id] : null
return id != null ? (this._nodes_by_id[id] ?? null) : null
}
getNodeByRawId(id: NodeIdInput | null | undefined): LGraphNode | null {
return id != null ? this.getNodeById(asNodeId(id)) : null
}
/**
@@ -1389,10 +1406,9 @@ export class LGraph
}
this.floatingLinksInternal.set(link.id, link)
const slot =
link.target_id !== -1
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
const slot = !isFloatingNodeId(link.target_id)
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks ??= new Set()
slot._floatingLinks.add(link)
@@ -1412,10 +1428,9 @@ export class LGraph
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
const slot =
link.target_id !== -1
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
const slot = !isFloatingNodeId(link.target_id)
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks?.delete(link)
}
@@ -1715,11 +1730,11 @@ export class LGraph
id: createUuidv4(),
name: 'New Subgraph',
inputNode: {
id: SUBGRAPH_INPUT_ID,
id: SUBGRAPH_INPUT_NODE_ID,
bounding: [0, 0, 75, 100]
},
outputNode: {
id: SUBGRAPH_OUTPUT_ID,
id: SUBGRAPH_OUTPUT_NODE_ID,
bounding: [0, 0, 75, 100]
},
inputs,
@@ -1816,7 +1831,7 @@ export class LGraph
// Special handling: Subgraph input node
i++
if (link.origin_id === SUBGRAPH_INPUT_ID) {
if (isSubgraphInputNodeId(link.origin_id)) {
link.target_id = subgraphNode.id
link.target_slot = i - 1
if (subgraphInput instanceof SubgraphInput) {
@@ -1857,7 +1872,7 @@ export class LGraph
for (const connection of connections) {
const { input, inputNode, link, subgraphOutput } = connection
// Special handling: Subgraph output node
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (isSubgraphOutputNodeId(link.target_id)) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
@@ -1962,9 +1977,10 @@ export class LGraph
}
}
nodeIdMap.set(n_info.id, ++this.last_node_id)
node.id = this.last_node_id
n_info.id = this.last_node_id
const newId = asNodeId(++this.last_node_id)
nodeIdMap.set(n_info.id, newId)
node.id = newId
n_info.id = newId
// Strip links from serialized data before configure to prevent
// onConnectionsChange from resolving subgraph-internal link IDs
@@ -2029,7 +2045,7 @@ export class LGraph
}[] = []
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
if (isSubgraphInputNodeId(link.origin_id)) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
if (!outerLinkId) {
console.error('Missing Link ID when unpacking')
@@ -2047,7 +2063,7 @@ export class LGraph
}
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (isSubgraphOutputNodeId(link.target_id)) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
@@ -2098,7 +2114,7 @@ export class LGraph
const linkIdMap = new Map<LinkId, LinkId[]>()
for (const newLink of dedupedNewLinks) {
let created: LLink | null | undefined
if (newLink.oid == SUBGRAPH_INPUT_ID) {
if (isSubgraphInputNodeId(newLink.oid)) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
@@ -2108,7 +2124,7 @@ export class LGraph
tnode.inputs[newLink.tslot],
tnode
)
} else if (newLink.tid == SUBGRAPH_OUTPUT_ID) {
} else if (isSubgraphOutputNodeId(newLink.tid)) {
if (!(this instanceof Subgraph)) {
console.error('Ignoring link to subgraph outside subgraph')
continue
@@ -2417,6 +2433,8 @@ export class LGraph
if (!data) return
if (options.clearGraph) this.clear()
data = normalizeStringNodeIds(data, this.state)
this._configureBase(data)
let reroutes: SerialisableReroute[] | undefined
@@ -2499,15 +2517,18 @@ export class LGraph
if (subgraphs) {
const reservedNodeIds = new Set<number>()
for (const node of this._nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
if (isNumericNodeId(node.id))
reservedNodeIds.add(nodeIdToNumber(node.id))
}
for (const sg of this.subgraphs.values()) {
for (const node of sg.nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
if (isNumericNodeId(node.id))
reservedNodeIds.add(nodeIdToNumber(node.id))
}
}
for (const n of nodesData ?? []) {
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
if (n.id != null && isNumericNodeId(n.id))
reservedNodeIds.add(nodeIdToNumber(n.id))
}
const deduplicated = this.isRootGraph
@@ -2554,7 +2575,7 @@ export class LGraph
}
// id it or it will create a new id
node.id = n_info.id
if (n_info.id != null) node.id = asNodeId(n_info.id)
// add before configure, otherwise configure cannot create links
this.add(node, true)
nodeDataMap.set(node.id, n_info)
@@ -2676,24 +2697,22 @@ export class LGraph
const remappedIds = new Map<NodeId, NodeId>()
for (const node of graph._nodes) {
if (typeof node.id !== 'number') continue
if (!isNumericNodeId(node.id)) continue
if (usedNodeIds.has(node.id)) {
const numericId = nodeIdToNumber(node.id)
if (usedNodeIds.has(numericId)) {
const oldId = node.id
while (usedNodeIds.has(++state.lastNodeId));
const newId = state.lastNodeId
const newId = nextFreeNodeId(usedNodeIds, state)
delete graph._nodes_by_id[oldId]
node.id = newId
graph._nodes_by_id[newId] = node
usedNodeIds.add(newId)
remappedIds.set(oldId, newId)
console.warn(
`LiteGraph: duplicate node ID ${oldId} reassigned to ${newId} in graph ${graph.id}`
)
} else {
usedNodeIds.add(node.id as number)
if ((node.id as number) > state.lastNodeId)
state.lastNodeId = node.id as number
usedNodeIds.add(numericId)
if (numericId > state.lastNodeId) state.lastNodeId = numericId
}
}
@@ -2877,16 +2896,16 @@ export class Subgraph
*/
private _repairIOSlotLinkIds(): void {
for (const [slotIndex, slot] of this.inputs.entries()) {
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_INPUT_ID, slotIndex)
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_INPUT_NODE_ID, slotIndex)
}
for (const [slotIndex, slot] of this.outputs.entries()) {
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_OUTPUT_ID, slotIndex)
this._repairSlotLinkIds(slot.linkIds, SUBGRAPH_OUTPUT_NODE_ID, slotIndex)
}
}
private _repairSlotLinkIds(
linkIds: LinkId[],
ioNodeId: number,
ioNodeId: NodeId,
slotIndex: number
): void {
const repaired = linkIds.map((id) =>
@@ -2900,7 +2919,7 @@ export class Subgraph
}
private _findLinkBySlot(
nodeId: number,
nodeId: NodeId,
slotIndex: number
): LLink | undefined {
for (const link of this._links.values()) {

View File

@@ -6,11 +6,13 @@ import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxy
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
asNodeId,
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode,
UNASSIGNED_NODE_ID,
createUuidv4
} from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
@@ -19,6 +21,7 @@ import type {
ExportedSubgraph,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/types/nodeId'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
@@ -35,7 +38,7 @@ function createSerialisedNode(
proxyWidgets?: Array<[string, string]>
): ISerialisedNode {
return {
id,
id: asNodeId(id),
type,
pos: [0, 0],
size: [140, 80],
@@ -52,7 +55,7 @@ describe('remapClipboardSubgraphNodeIds', () => {
it('remaps pasted subgraph interior IDs and proxyWidgets references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = 1
existingNode.id = asNodeId(1)
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
@@ -69,11 +72,11 @@ describe('remapClipboardSubgraphNodeIds', () => {
config: {},
name: 'Pasted Subgraph',
inputNode: {
id: -10,
id: asNodeId(-10),
bounding: [0, 0, 10, 10]
},
outputNode: {
id: -20,
id: asNodeId(-20),
bounding: [0, 0, 10, 10]
},
inputs: [],
@@ -84,9 +87,9 @@ describe('remapClipboardSubgraphNodeIds', () => {
{
id: 1,
type: '*',
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 1,
target_id: asNodeId(1),
target_slot: 0
}
],
@@ -121,10 +124,85 @@ describe('remapClipboardSubgraphNodeIds', () => {
])
})
it('remaps legacy numeric interior IDs, links, and proxyWidgets references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = asNodeId(1)
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
// Legacy clipboard JSON written by a pre-branding frontend: numeric ids.
const legacyNumericId = 1 as unknown as NodeId
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
config: {},
name: 'Pasted Subgraph',
inputNode: { id: asNodeId(-10), bounding: [0, 0, 10, 10] },
outputNode: { id: asNodeId(-20), bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: legacyNumericId,
type: 'test/node',
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
],
links: [
{
id: 1,
type: '*',
origin_id: 1 as unknown as NodeId,
origin_slot: 0,
target_id: 1 as unknown as NodeId,
target_slot: 0
}
],
groups: []
}
const hostInfo = createSerialisedNode(99, subgraphId, [
[1 as unknown as string, 'seed']
])
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(remappedInteriorId).not.toBe('1')
const remappedLink = parsed.subgraphs?.[0]?.links?.[0]
expect(remappedLink?.origin_id).toBe(remappedInteriorId)
expect(remappedLink?.target_id).toBe(remappedInteriorId)
expect(parsed.nodes?.[0]?.properties?.proxyWidgets).toStrictEqual([
[String(remappedInteriorId), 'seed']
])
})
it('remaps pasted SubgraphNode previewExposures sourceNodeId references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = 1
existingNode.id = asNodeId(1)
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
@@ -140,8 +218,8 @@ describe('remapClipboardSubgraphNodeIds', () => {
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputNode: { id: asNodeId(-10), bounding: [0, 0, 10, 10] },
outputNode: { id: asNodeId(-20), bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
@@ -173,13 +251,18 @@ describe('remapClipboardSubgraphNodeIds', () => {
const remappedInteriorId = parsed.subgraphs?.[0]?.nodes?.[0]?.id
expect(remappedInteriorId).not.toBe(1)
expect(parsed.nodes?.[0]?.properties?.previewExposures).toStrictEqual([
expect(typeof remappedInteriorId).toBe('number')
const previewExposures = parsed.nodes?.[0]?.properties?.previewExposures
expect(previewExposures).toStrictEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: String(remappedInteriorId),
sourceNodeId: remappedInteriorId,
sourcePreviewName: '$$canvas-image-preview'
}
])
if (!Array.isArray(previewExposures))
throw new Error('Expected previewExposures')
expect(typeof previewExposures?.[0]?.sourceNodeId).toBe('number')
})
})
@@ -209,7 +292,7 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(rootGraph, subgraph as Subgraph, {
id: -1,
id: UNASSIGNED_NODE_ID,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
@@ -264,14 +347,14 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputNode: { id: asNodeId(-10), bounding: [0, 0, 10, 10] },
outputNode: { id: asNodeId(-20), bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorId,
id: asNodeId(interiorId),
type: 'test/inner',
pos: [0, 0],
size: [140, 80],
@@ -288,7 +371,7 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
}
const hostInfo: ISerialisedNode = {
id: 99,
id: asNodeId(99),
type: subgraphId,
pos: [0, 0],
size: [140, 80],
@@ -340,14 +423,14 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputNode: { id: asNodeId(-10), bounding: [0, 0, 10, 10] },
outputNode: { id: asNodeId(-20), bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorPreviewId,
id: asNodeId(interiorPreviewId),
type: 'PreviewImage',
pos: [0, 0],
size: [140, 80],
@@ -364,7 +447,7 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
}
const hostInfo: ISerialisedNode = {
id: 99,
id: asNodeId(99),
type: subgraphId,
pos: [0, 0],
size: [140, 80],
@@ -398,7 +481,7 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
const interiorIdAfterRemap = pastedHost!.subgraph.nodes[0].id
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(interiorIdAfterRemap),
sourceNodeId: interiorIdAfterRemap,
sourcePreviewName: '$$canvas-image-preview'
})
])

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeLayout } from '@/renderer/core/layout/types'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
import {
LGraph,
@@ -74,7 +74,7 @@ function createCanvas(graph: LGraph): LGraphCanvas {
}
function createLayoutEntry(node: LGraphNode, zIndex: number) {
const nodeId = String(node.id)
const nodeId = node.id
const layout: NodeLayout = {
id: nodeId,
position: { x: node.pos[0], y: node.pos[1] },
@@ -99,7 +99,7 @@ function createLayoutEntry(node: LGraphNode, zIndex: number) {
})
}
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
function setZIndex(nodeId: NodeId, zIndex: number, previousZIndex: number) {
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
@@ -145,7 +145,7 @@ describe('cloned node z-index in Vue renderer', () => {
originalNode.size = [200, 100]
graph.add(originalNode)
const originalNodeId = String(originalNode.id)
const originalNodeId = originalNode.id
setZIndex(originalNodeId, 5, 0)
@@ -158,7 +158,7 @@ describe('cloned node z-index in Vue renderer', () => {
expect(result!.created.length).toBe(1)
const clonedNode = result!.created[0] as LGraphNode
const clonedNodeId = String(clonedNode.id)
const clonedNodeId = clonedNode.id
// The cloned node should have a z-index higher than the original
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
@@ -171,13 +171,13 @@ describe('cloned node z-index in Vue renderer', () => {
nodeA.pos = [100, 100]
nodeA.size = [200, 100]
graph.add(nodeA)
setZIndex(String(nodeA.id), 3, 0)
setZIndex(nodeA.id, 3, 0)
const nodeB = new TestNode()
nodeB.pos = [400, 100]
nodeB.size = [200, 100]
graph.add(nodeB)
setZIndex(String(nodeB.id), 7, 0)
setZIndex(nodeB.id, 7, 0)
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
expect(result).toBeDefined()
@@ -185,8 +185,8 @@ describe('cloned node z-index in Vue renderer', () => {
const clonedA = result!.created[0] as LGraphNode
const clonedB = result!.created[1] as LGraphNode
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
const layoutA = layoutStore.getNodeLayoutRef(clonedA.id).value!
const layoutB = layoutStore.getNodeLayoutRef(clonedB.id).value!
// Both cloned nodes should be above the highest original (z-index 7)
expect(layoutA.zIndex).toBeGreaterThan(7)

View File

@@ -107,7 +107,7 @@ describe('LGraphCanvas slot hit detection', () => {
// Mock the slot query to return our node's slot
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
nodeId: node.id,
index: 0,
type: 'output',
position: { x: 252, y: 120 },
@@ -188,7 +188,7 @@ describe('LGraphCanvas slot hit detection', () => {
expect(node.isPointInside(clickX, clickY)).toBe(false)
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
nodeId: node.id,
index: 0,
type: 'input',
position: { x: 98, y: 140 },

View File

@@ -112,6 +112,14 @@ import type { NeverNever, PickNevers } from './types/utility'
import type { IBaseWidget, TWidgetValue } from './types/widgets'
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
import { findFirstNode, getAllNestedItems } from './utils/collections'
import type { NodeIdInput } from '@/types/nodeId'
import {
asNodeId,
isNumericNodeId,
nodeIdToNumber,
tryAsNodeId,
UNASSIGNED_NODE_ID
} from '@/types/nodeId'
import { resolveConnectingLinkColor } from './utils/linkColors'
import { createUuidv4 } from '@/utils/uuid'
import { BaseWidget } from './widgets/BaseWidget'
@@ -3359,7 +3367,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
let outputId: number = -1
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
if (slotLayout && slotLayout.nodeId === String(node.id)) {
if (slotLayout && slotLayout.nodeId === node.id) {
if (slotLayout.type === 'input') {
inputId = slotLayout.index
pos[0] = slotLayout.position.x
@@ -4216,7 +4224,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
nodes.set(info.id, node)
info.id = -1
info.id = UNASSIGNED_NODE_ID
graph.add(node)
node.configure(info)
@@ -4309,7 +4317,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const newPositions = created
.filter((item): item is LGraphNode => item instanceof LGraphNode)
.map((node) => ({
nodeId: String(node.id),
nodeId: node.id,
bounds: {
x: node.pos[0],
y: node.pos[1],
@@ -8980,26 +8988,30 @@ function patchLinkNodeIds(
if (!links?.length) return
for (const link of links) {
const newOriginId = remappedIds.get(link.origin_id)
const newOriginId = remappedIds.get(asNodeId(link.origin_id))
if (newOriginId !== undefined) link.origin_id = newOriginId
const newTargetId = remappedIds.get(link.target_id)
const newTargetId = remappedIds.get(asNodeId(link.target_id))
if (newTargetId !== undefined) link.target_id = newTargetId
}
}
function isNodeIdValue(value: unknown): value is NodeIdInput {
return typeof value === 'string' || typeof value === 'number'
}
/**
* Looks up a remap, tolerating legacy numeric ids and skipping sentinels.
* Invalid metadata ids (e.g. non-decimal strings from legacy/extension
* `proxyWidgets` or `previewExposures`) are skipped rather than aborting paste.
*/
function remapNodeId(
nodeId: string,
nodeId: NodeIdInput,
remappedIds: Map<NodeId, NodeId>
): NodeId | undefined {
const directMatch = remappedIds.get(nodeId)
if (directMatch !== undefined) return directMatch
if (!/^-?\d+$/.test(nodeId)) return undefined
const numericId = Number(nodeId)
if (!Number.isSafeInteger(numericId)) return undefined
return remappedIds.get(numericId)
const normalized = tryAsNodeId(nodeId)
if (normalized === null || normalized === UNASSIGNED_NODE_ID) return undefined
return remappedIds.get(normalized)
}
function remapProxyWidgets(
@@ -9015,21 +9027,21 @@ function remapProxyWidgets(
if (!Array.isArray(entry)) continue
const [nodeId] = entry
if (typeof nodeId !== 'string' || nodeId === '-1') continue
if (!isNodeIdValue(nodeId)) continue
const remappedNodeId = remapNodeId(nodeId, remappedIds)
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
}
}
function hasStringSourceNodeId(
function hasSourceNodeId(
value: unknown
): value is { sourceNodeId: string } {
): value is { sourceNodeId: NodeIdInput } {
return (
typeof value === 'object' &&
value !== null &&
'sourceNodeId' in value &&
typeof value.sourceNodeId === 'string'
isNodeIdValue(value.sourceNodeId)
)
}
@@ -9043,11 +9055,10 @@ function remapPreviewExposures(
if (!Array.isArray(previewExposures)) return
for (const entry of previewExposures) {
if (!hasStringSourceNodeId(entry) || entry.sourceNodeId === '-1') continue
if (!hasSourceNodeId(entry)) continue
const remappedNodeId = remapNodeId(entry.sourceNodeId, remappedIds)
if (remappedNodeId !== undefined)
entry.sourceNodeId = String(remappedNodeId)
if (remappedNodeId !== undefined) entry.sourceNodeId = remappedNodeId
}
}
@@ -9057,17 +9068,18 @@ export function remapClipboardSubgraphNodeIds(
): void {
const usedNodeIds = new Set<number>()
forEachNode(rootGraph, (node) => {
if (typeof node.id !== 'number') return
usedNodeIds.add(node.id)
if (rootGraph.state.lastNodeId < node.id)
rootGraph.state.lastNodeId = node.id
if (!isNumericNodeId(node.id)) return
const numericId = nodeIdToNumber(node.id)
usedNodeIds.add(numericId)
if (rootGraph.state.lastNodeId < numericId)
rootGraph.state.lastNodeId = numericId
})
function nextUniqueNodeId() {
function nextUniqueNodeId(): NodeId {
while (usedNodeIds.has(++rootGraph.state.lastNodeId));
const nextId = rootGraph.state.lastNodeId
usedNodeIds.add(nextId)
return nextId
return asNodeId(nextId)
}
const subgraphNodeIdMap = new Map<SubgraphId, Map<NodeId, NodeId>>()
@@ -9076,19 +9088,20 @@ export function remapClipboardSubgraphNodeIds(
const interiorNodes = subgraphInfo.nodes ?? []
for (const nodeInfo of interiorNodes) {
if (typeof nodeInfo.id !== 'number') continue
if (!isNumericNodeId(nodeInfo.id)) continue
if (usedNodeIds.has(nodeInfo.id)) {
const oldId = nodeInfo.id
const numericId = nodeIdToNumber(nodeInfo.id)
if (usedNodeIds.has(numericId)) {
const oldId = asNodeId(nodeInfo.id)
const newId = nextUniqueNodeId()
remappedIds.set(oldId, newId)
nodeInfo.id = newId
continue
}
usedNodeIds.add(nodeInfo.id)
if (rootGraph.state.lastNodeId < nodeInfo.id)
rootGraph.state.lastNodeId = nodeInfo.id
usedNodeIds.add(numericId)
if (rootGraph.state.lastNodeId < numericId)
rootGraph.state.lastNodeId = numericId
}
if (remappedIds.size > 0) {

View File

@@ -9,6 +9,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import {
asNodeId,
LGraphNode,
LiteGraph,
LGraph,
@@ -107,7 +108,7 @@ describe('LGraphNode', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
id: asNodeId(0),
inputs: [{ name: 'TestInput', type: 'number', link: null }]
})
)
@@ -117,8 +118,8 @@ describe('LGraphNode', () => {
expect(node.inputs[0]).instanceOf(NodeInputSlot)
// Should not override existing inputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
node.configure(getMockISerialisedNode({ id: asNodeId(1) }))
expect(node.id).toEqual(asNodeId(1))
expect(node.inputs.length).toEqual(1)
})
@@ -126,7 +127,7 @@ describe('LGraphNode', () => {
const node = new LGraphNode('TestNode')
node.configure(
getMockISerialisedNode({
id: 0,
id: asNodeId(0),
outputs: [{ name: 'TestOutput', type: 'number', links: [] }]
})
)
@@ -137,16 +138,16 @@ describe('LGraphNode', () => {
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
// Should not override existing outputs
node.configure(getMockISerialisedNode({ id: 1 }))
expect(node.id).toEqual(1)
node.configure(getMockISerialisedNode({ id: asNodeId(1) }))
expect(node.id).toEqual(asNodeId(1))
expect(node.outputs.length).toEqual(1)
})
test('should not allow configuring id to -1', () => {
const graph = new LGraph()
const node = new LGraphNode('TestNode')
graph.add(node)
node.configure(getMockISerialisedNode({ id: -1 }))
expect(node.id).not.toBe(-1)
node.configure(getMockISerialisedNode({ id: asNodeId(-1) }))
expect(node.id).not.toBe(asNodeId(-1))
})
describe('Disconnect I/O Slots', () => {
@@ -157,13 +158,13 @@ describe('LGraphNode', () => {
// Configure nodes with input/output slots
node1.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
node2.configure(
getMockISerialisedNode({
id: 2,
id: asNodeId(2),
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
@@ -209,7 +210,7 @@ describe('LGraphNode', () => {
// Configure nodes with input/output slots
sourceNode.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'number', links: [] }
@@ -218,13 +219,13 @@ describe('LGraphNode', () => {
)
targetNode1.configure(
getMockISerialisedNode({
id: 2,
id: asNodeId(2),
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
targetNode2.configure(
getMockISerialisedNode({
id: 3,
id: asNodeId(3),
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
@@ -319,7 +320,7 @@ describe('LGraphNode', () => {
node.updateArea()
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
@@ -342,7 +343,7 @@ describe('LGraphNode', () => {
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
@@ -363,7 +364,7 @@ describe('LGraphNode', () => {
node.size = [100, 100]
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
@@ -382,7 +383,7 @@ describe('LGraphNode', () => {
node.updateArea()
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
inputs: [
{ name: 'Input1', type: 'number', link: null },
{ name: 'Input2', type: 'string', link: null }
@@ -408,7 +409,7 @@ describe('LGraphNode', () => {
node.updateArea()
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
outputs: [
{ name: 'Output1', type: 'number', links: [] },
{ name: 'Output2', type: 'string', links: [] }
@@ -435,7 +436,7 @@ describe('LGraphNode', () => {
node.updateArea()
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
inputs: [{ name: 'Input1', type: 'number', link: null }],
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
@@ -575,7 +576,7 @@ describe('LGraphNode', () => {
node.widgets![0].serialize = false
node.configure(
getMockISerialisedNode({
id: 1,
id: asNodeId(1),
type: 'TestNode',
pos: [100, 100],
size: [100, 100],

View File

@@ -16,8 +16,15 @@ import {
toClass
} from '@/lib/litegraph/src/utils/type'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { cachedMeasureText } from '@/lib/litegraph/src/utils/textMeasureCache'
import {
UNASSIGNED_NODE_ID,
asNodeId,
isSubgraphInputNodeId,
isSubgraphOutputNodeId,
isUnassignedNodeId
} from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
@@ -98,7 +105,7 @@ import type { WidgetTypeMap } from './widgets/widgetMap'
// #region Types
export type NodeId = number | string
export type { NodeId } from '@/types/nodeId'
export type NodeProperty = string | number | boolean | object
@@ -501,7 +508,7 @@ export class LGraphNode
const mutations = useLayoutMutations()
mutations.setSource(LayoutSource.Canvas)
mutations.moveNode(String(this.id), { x: value[0], y: value[1] })
mutations.moveNode(this.id, { x: value[0], y: value[1] })
}
/**
@@ -523,7 +530,7 @@ export class LGraphNode
const mutations = useLayoutMutations()
mutations.setSource(LayoutSource.Canvas)
mutations.resizeNode(String(this.id), {
mutations.resizeNode(this.id, {
width: value[0],
height: value[1]
})
@@ -810,7 +817,7 @@ export class LGraphNode
}
constructor(title: string, type?: string) {
this.id = -1
this.id = UNASSIGNED_NODE_ID
this.title = title || 'Unnamed'
this.type = type ?? ''
this.size = [LiteGraph.NODE_WIDTH, 60]
@@ -833,7 +840,7 @@ export class LGraphNode
if (this.graph) {
this.graph.incrementVersion()
}
if (info.id === -1) info.id = this.id
if (isUnassignedNodeId(info.id)) info.id = this.id
for (const j in info) {
if (j == 'properties') {
// i don't want to clone properties, I want to reuse the old container
@@ -864,6 +871,8 @@ export class LGraphNode
}
}
if (!isUnassignedNodeId(this.id)) this.id = asNodeId(this.id)
if (!info.title) {
this.title = this.constructor.title
}
@@ -1413,17 +1422,14 @@ export class LGraphNode
options.action_call ||= `${this.id}_exec_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error Technically it works when id is a string. Array gets props.
this.graph.nodes_executing[this.id] = true
this.onExecute(param, options)
// @ts-expect-error deprecated
this.graph.nodes_executing[this.id] = false
// save execution/action ref
this.exec_version = this.graph.iteration
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
@@ -1447,16 +1453,13 @@ export class LGraphNode
options.action_call ||= `${this.id}_${action || 'action'}_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = action || 'actioning'
this.onAction(action, param, options)
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = false
// save execution/action ref
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
@@ -1999,9 +2002,9 @@ export class LGraphNode
this._widgetSlotsDirty = true
// Only register with store if node has a valid ID (is already in a graph).
// If the node isn't in a graph yet (id === -1), registration happens
// If the node isn't in a graph yet, registration happens
// when the node is added via LGraph.add() -> node.onAdded.
if (this.id !== -1 && isNodeBindable(widget)) {
if (!isUnassignedNodeId(this.id) && isNodeBindable(widget)) {
widget.setNodeId(this.id)
}
@@ -3162,7 +3165,7 @@ export class LGraphNode
const link_info = graph._links.get(link_id)
if (!link_info) continue
if (
link_info.target_id === SUBGRAPH_OUTPUT_ID &&
isSubgraphOutputNodeId(link_info.target_id) &&
graph instanceof Subgraph
) {
const targetSlot = graph.outputNode.slots[link_info.target_slot]
@@ -3272,7 +3275,10 @@ export class LGraphNode
const link_info = graph._links.get(link_id)
if (link_info) {
// Let SubgraphInput do the disconnect.
if (link_info.origin_id === -10 && 'inputNode' in graph) {
if (
isSubgraphInputNodeId(link_info.origin_id) &&
'inputNode' in graph
) {
graph.inputNode._disconnectNodeInput(this, input, link_info)
return true
}

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { asNodeId, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
@@ -23,7 +23,7 @@ describe('LGraphNode widget ordering', () => {
// Configure with widget values
const info: ISerialisedNode = {
id: 1,
id: asNodeId(1),
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
@@ -50,7 +50,7 @@ describe('LGraphNode widget ordering', () => {
// Widget values are in input_order: [steps, seed, prompt]
const info: ISerialisedNode = {
id: 1,
id: asNodeId(1),
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
@@ -81,7 +81,7 @@ describe('LGraphNode widget ordering', () => {
node.addWidget('number', 'seed', 0, null, {})
const info: ISerialisedNode = {
id: 1,
id: asNodeId(1),
type: 'TestNode',
pos: [0, 0],
size: [200, 100],

View File

@@ -1,17 +1,80 @@
import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import {
asNodeId,
UNASSIGNED_NODE_ID,
LLink,
SUBGRAPH_INPUT_ID,
SUBGRAPH_INPUT_NODE_ID,
SUBGRAPH_OUTPUT_ID,
SUBGRAPH_OUTPUT_NODE_ID
} from '@/lib/litegraph/src/litegraph'
import { test } from './__fixtures__/testExtensions'
describe('LLink', () => {
test('matches previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
const link = new LLink(1, 'float', asNodeId(4), 2, asNodeId(5), 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
test('serializes to the previous snapshot', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
const link = new LLink(1, 'float', asNodeId(4), 2, asNodeId(5), 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
describe('node-id branding at the edges', () => {
test('brands numeric endpoints into NodeId at construction', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.origin_id).toBe(asNodeId(4))
expect(link.target_id).toBe(asNodeId(5))
expect(typeof link.origin_id).toBe('number')
})
test('brands the floating sentinel into UNASSIGNED_NODE_ID', () => {
const link = new LLink(1, 'float', -1, -1, asNodeId(5), 3)
expect(link.origin_id).toBe(UNASSIGNED_NODE_ID)
expect(link.isFloatingOutput).toBe(true)
})
test('brands subgraph IO sentinels into branded sentinels', () => {
const link = new LLink(
1,
'float',
SUBGRAPH_INPUT_ID,
0,
SUBGRAPH_OUTPUT_ID,
0
)
expect(link.origin_id).toBe(SUBGRAPH_INPUT_NODE_ID)
expect(link.target_id).toBe(SUBGRAPH_OUTPUT_NODE_ID)
expect(link.originIsIoNode).toBe(true)
expect(link.targetIsIoNode).toBe(true)
})
test('serializes sentinels as branded string ids', () => {
const floating = new LLink(1, 'float', -1, -1, asNodeId(5), 3)
expect(floating.asSerialisable().origin_id).toBe(UNASSIGNED_NODE_ID)
expect(floating.serialize()[1]).toBe(UNASSIGNED_NODE_ID)
const io = new LLink(
2,
'float',
SUBGRAPH_INPUT_ID,
0,
SUBGRAPH_OUTPUT_ID,
0
)
const serialised = io.asSerialisable()
expect(serialised.origin_id).toBe(SUBGRAPH_INPUT_NODE_ID)
expect(serialised.target_id).toBe(SUBGRAPH_OUTPUT_NODE_ID)
})
test('serializes real node ids as branded strings (round-trips)', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
const restored = LLink.create(link.asSerialisable())
expect(restored.origin_id).toBe(asNodeId(4))
expect(restored.target_id).toBe(asNodeId(5))
})
})
})

View File

@@ -1,13 +1,17 @@
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { NodeIdInput } from '@/types/nodeId'
import {
UNASSIGNED_NODE_ID,
asNodeId,
isFloatingNodeId,
isSubgraphInputNodeId,
isSubgraphOutputNodeId
} from '@/types/nodeId'
import type { Reroute, RerouteId } from './Reroute'
import type {
CanvasColour,
@@ -130,11 +134,11 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
}
public get isFloatingOutput(): boolean {
return this.origin_id === -1 && this.origin_slot === -1
return isFloatingNodeId(this.origin_id) && this.origin_slot === -1
}
public get isFloatingInput(): boolean {
return this.target_id === -1 && this.target_slot === -1
return isFloatingNodeId(this.target_id) && this.target_slot === -1
}
public get isFloating(): boolean {
@@ -143,28 +147,28 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
/** `true` if this link is connected to a subgraph input node (the actual origin is in a different graph). */
get originIsIoNode(): boolean {
return this.origin_id === SUBGRAPH_INPUT_ID
return isSubgraphInputNodeId(this.origin_id)
}
/** `true` if this link is connected to a subgraph output node (the actual target is in a different graph). */
get targetIsIoNode(): boolean {
return this.target_id === SUBGRAPH_OUTPUT_ID
return isSubgraphOutputNodeId(this.target_id)
}
constructor(
id: LinkId,
type: ISlotType,
origin_id: NodeId,
origin_id: NodeIdInput,
origin_slot: number,
target_id: NodeId,
target_id: NodeIdInput,
target_slot: number,
parentId?: RerouteId
) {
this.id = id
this.type = type
this.origin_id = origin_id
this.origin_id = asNodeId(origin_id)
this.origin_slot = origin_slot
this.target_id = target_id
this.target_id = asNodeId(target_id)
this.target_slot = target_slot
this.parentId = parentId
@@ -307,10 +311,9 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
* it is recommended to use simpler methods where appropriate.
*/
resolve(network: BasicReadonlyNetwork): ResolvedConnection {
const inputNode =
this.target_id === -1
? undefined
: (network.getNodeById(this.target_id) ?? undefined)
const inputNode = isFloatingNodeId(this.target_id)
? undefined
: (network.getNodeById(this.target_id) ?? undefined)
const input = inputNode?.inputs[this.target_slot]
const subgraphInput = this.originIsIoNode
? network.inputNode?.slots[this.origin_slot]
@@ -319,10 +322,9 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return { inputNode, input, subgraphInput, link: this }
}
const outputNode =
this.origin_id === -1
? undefined
: (network.getNodeById(this.origin_id) ?? undefined)
const outputNode = isFloatingNodeId(this.origin_id)
? undefined
: (network.getNodeById(this.origin_id) ?? undefined)
const output = outputNode?.outputs[this.origin_slot]
const subgraphOutput = this.targetIsIoNode
? network.outputNode?.slots[this.target_slot]
@@ -351,17 +353,17 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
configure(o: LLink | SerialisedLLinkArray) {
if (Array.isArray(o)) {
this.id = o[0]
this.origin_id = o[1]
this.origin_id = asNodeId(o[1])
this.origin_slot = o[2]
this.target_id = o[3]
this.target_id = asNodeId(o[3])
this.target_slot = o[4]
this.type = o[5]
} else {
this.id = o.id
this.type = o.type
this.origin_id = o.origin_id
this.origin_id = asNodeId(o.origin_id)
this.origin_slot = o.origin_slot
this.target_id = o.target_id
this.target_id = asNodeId(o.target_id)
this.target_slot = o.target_slot
this.parentId = o.parentId
}
@@ -399,10 +401,10 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
exported.parentId = parentId
if (slotType === 'input') {
exported.origin_id = -1
exported.origin_id = UNASSIGNED_NODE_ID
exported.origin_slot = -1
} else {
exported.target_id = -1
exported.target_id = UNASSIGNED_NODE_ID
exported.target_slot = -1
}
@@ -432,12 +434,12 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
newLink.id = -1
if (keepReroutes === 'input') {
newLink.origin_id = -1
newLink.origin_id = UNASSIGNED_NODE_ID
newLink.origin_slot = -1
lastReroute.floating = { slotType: 'input' }
} else {
newLink.target_id = -1
newLink.target_id = UNASSIGNED_NODE_ID
newLink.target_slot = -1
lastReroute.floating = { slotType: 'output' }

View File

@@ -3,6 +3,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraphNode, NodeId } from './LGraphNode'
import { isFloatingNodeId } from '@/types/nodeId'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
import type {
@@ -372,7 +373,7 @@ export class Reroute
for (const linkId of this.floatingLinkIds) {
const link = floatingLinks.get(linkId)
if (link?.[idProp] === -1) out.push(link)
if (link && isFloatingNodeId(link[idProp])) out.push(link)
}
return out
}
@@ -807,7 +808,7 @@ function getNextPos(
if (linkPos) return linkPos
// Floating link with no input to find
if (link.target_id === -1 || link.target_slot === -1) return
if (isFloatingNodeId(link.target_id) || link.target_slot === -1) return
return network.getNodeById(link.target_id)?.getInputPos(link.target_slot)
}

View File

@@ -20,7 +20,7 @@ export const oldSchemaGraph: ISerialisedGraph = {
title: 'A group to test with'
}
],
nodes: [{ id: 1 } as Partial<ISerialisedNode> as ISerialisedNode],
nodes: [{ id: 1 } as unknown as ISerialisedNode],
links: []
}

View File

@@ -1,4 +1,5 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
/**
* Root graph with two nodes (Source, Target) connected by one valid link
@@ -17,7 +18,7 @@ export const duplicateLinksRoot: SerialisableGraph = {
},
nodes: [
{
id: 1,
id: asNodeId(1),
type: 'test/DupTestNode',
pos: [0, 0],
size: [200, 100],
@@ -29,7 +30,7 @@ export const duplicateLinksRoot: SerialisableGraph = {
properties: {}
},
{
id: 2,
id: asNodeId(2),
type: 'test/DupTestNode',
pos: [300, 0],
size: [200, 100],
@@ -44,25 +45,25 @@ export const duplicateLinksRoot: SerialisableGraph = {
links: [
{
id: 1,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
},
{
id: 2,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
},
{
id: 3,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
}
@@ -87,7 +88,7 @@ export const duplicateLinksSlotShift: SerialisableGraph = {
},
nodes: [
{
id: 1,
id: asNodeId(1),
type: 'test/DupTestNode',
pos: [0, 0],
size: [200, 100],
@@ -99,7 +100,7 @@ export const duplicateLinksSlotShift: SerialisableGraph = {
properties: {}
},
{
id: 2,
id: asNodeId(2),
type: 'test/DupTestNode',
pos: [300, 0],
size: [200, 100],
@@ -117,17 +118,17 @@ export const duplicateLinksSlotShift: SerialisableGraph = {
links: [
{
id: 1,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
},
{
id: 2,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
}
@@ -151,7 +152,7 @@ export const duplicateLinksSubgraph: SerialisableGraph = {
},
nodes: [
{
id: 1,
id: asNodeId(1),
type: 'dd111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
@@ -175,14 +176,14 @@ export const duplicateLinksSubgraph: SerialisableGraph = {
},
name: 'Subgraph With Duplicates',
config: {},
inputNode: { id: -10, bounding: [0, 100, 120, 60] },
outputNode: { id: -20, bounding: [500, 100, 120, 60] },
inputNode: { id: asNodeId(-10), bounding: [0, 100, 120, 60] },
outputNode: { id: asNodeId(-20), bounding: [500, 100, 120, 60] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: 1,
id: asNodeId(1),
type: 'test/Source',
pos: [100, 100],
size: [200, 100],
@@ -194,7 +195,7 @@ export const duplicateLinksSubgraph: SerialisableGraph = {
properties: {}
},
{
id: 2,
id: asNodeId(2),
type: 'test/Target',
pos: [400, 100],
size: [200, 100],
@@ -210,25 +211,25 @@ export const duplicateLinksSubgraph: SerialisableGraph = {
links: [
{
id: 1,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
},
{
id: 2,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
},
{
id: 3,
origin_id: 1,
origin_id: asNodeId(1),
origin_slot: 0,
target_id: 2,
target_id: asNodeId(2),
target_slot: 0,
type: 'number'
}

View File

@@ -1,4 +1,5 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
/**
* Workflow with two subgraph definitions whose internal nodes share
@@ -20,7 +21,7 @@ export const duplicateSubgraphNodeIds = {
},
nodes: [
{
id: 102,
id: asNodeId(102),
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
@@ -30,7 +31,7 @@ export const duplicateSubgraphNodeIds = {
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
id: asNodeId(103),
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
@@ -54,14 +55,14 @@ export const duplicateSubgraphNodeIds = {
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
widgets: [{ id: asNodeId(3), name: 'seed' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -70,7 +71,7 @@ export const duplicateSubgraphNodeIds = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -79,7 +80,7 @@ export const duplicateSubgraphNodeIds = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -91,9 +92,9 @@ export const duplicateSubgraphNodeIds = {
links: [
{
id: 1,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 8,
target_id: asNodeId(8),
target_slot: 0,
type: 'number'
}
@@ -112,14 +113,14 @@ export const duplicateSubgraphNodeIds = {
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
widgets: [{ id: asNodeId(8), name: 'prompt' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -128,7 +129,7 @@ export const duplicateSubgraphNodeIds = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -137,7 +138,7 @@ export const duplicateSubgraphNodeIds = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -149,9 +150,9 @@ export const duplicateSubgraphNodeIds = {
links: [
{
id: 2,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 37,
target_id: asNodeId(37),
target_slot: 0,
type: 'string'
}

View File

@@ -1,4 +1,5 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
/**
* Workflow where SubgraphA contains a nested SubgraphNode referencing
@@ -24,7 +25,7 @@ export const nestedSubgraphProxyWidgets = {
},
nodes: [
{
id: 102,
id: asNodeId(102),
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
@@ -34,7 +35,7 @@ export const nestedSubgraphProxyWidgets = {
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
id: asNodeId(103),
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
@@ -58,14 +59,14 @@ export const nestedSubgraphProxyWidgets = {
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
widgets: [{ id: asNodeId(3), name: 'seed' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -74,7 +75,7 @@ export const nestedSubgraphProxyWidgets = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -83,7 +84,7 @@ export const nestedSubgraphProxyWidgets = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -92,7 +93,7 @@ export const nestedSubgraphProxyWidgets = {
mode: 0
},
{
id: 50,
id: asNodeId(50),
type: '22222222-2222-4222-8222-222222222222',
pos: [200, 0],
size: [100, 50],
@@ -105,9 +106,9 @@ export const nestedSubgraphProxyWidgets = {
links: [
{
id: 1,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 8,
target_id: asNodeId(8),
target_slot: 0,
type: 'number'
}
@@ -126,14 +127,14 @@ export const nestedSubgraphProxyWidgets = {
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
widgets: [{ id: asNodeId(8), name: 'prompt' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -142,7 +143,7 @@ export const nestedSubgraphProxyWidgets = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -151,7 +152,7 @@ export const nestedSubgraphProxyWidgets = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -163,9 +164,9 @@ export const nestedSubgraphProxyWidgets = {
links: [
{
id: 2,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 37,
target_id: asNodeId(37),
target_slot: 0,
type: 'string'
}

View File

@@ -1,4 +1,5 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
/**
* Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000)
@@ -20,7 +21,7 @@ export const nodeIdSpaceExhausted = {
},
nodes: [
{
id: 102,
id: asNodeId(102),
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
@@ -30,7 +31,7 @@ export const nodeIdSpaceExhausted = {
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
id: asNodeId(103),
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
@@ -40,7 +41,7 @@ export const nodeIdSpaceExhausted = {
properties: { proxyWidgets: [['8', 'prompt']] }
},
{
id: 100_000_000,
id: asNodeId(100_000_000),
type: 'dummy',
pos: [600, 0],
size: [100, 50],
@@ -63,14 +64,14 @@ export const nodeIdSpaceExhausted = {
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
widgets: [{ id: asNodeId(3), name: 'seed' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -79,7 +80,7 @@ export const nodeIdSpaceExhausted = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -88,7 +89,7 @@ export const nodeIdSpaceExhausted = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -100,9 +101,9 @@ export const nodeIdSpaceExhausted = {
links: [
{
id: 1,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 8,
target_id: asNodeId(8),
target_slot: 0,
type: 'number'
}
@@ -121,14 +122,14 @@ export const nodeIdSpaceExhausted = {
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
widgets: [{ id: asNodeId(8), name: 'prompt' }],
nodes: [
{
id: 3,
id: asNodeId(3),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -137,7 +138,7 @@ export const nodeIdSpaceExhausted = {
mode: 0
},
{
id: 8,
id: asNodeId(8),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -146,7 +147,7 @@ export const nodeIdSpaceExhausted = {
mode: 0
},
{
id: 37,
id: asNodeId(37),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -158,9 +159,9 @@ export const nodeIdSpaceExhausted = {
links: [
{
id: 2,
origin_id: 3,
origin_id: asNodeId(3),
origin_slot: 0,
target_id: 37,
target_id: asNodeId(37),
target_slot: 0,
type: 'string'
}

View File

@@ -1,4 +1,5 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { asNodeId } from '@/lib/litegraph/src/litegraph'
/**
* Workflow with two subgraph definitions whose internal nodes already
@@ -20,7 +21,7 @@ export const uniqueSubgraphNodeIds = {
},
nodes: [
{
id: 102,
id: asNodeId(102),
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
@@ -30,7 +31,7 @@ export const uniqueSubgraphNodeIds = {
properties: { proxyWidgets: [['10', 'seed']] }
},
{
id: 103,
id: asNodeId(103),
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
@@ -54,14 +55,14 @@ export const uniqueSubgraphNodeIds = {
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 10, name: 'seed' }],
widgets: [{ id: asNodeId(10), name: 'seed' }],
nodes: [
{
id: 10,
id: asNodeId(10),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -70,7 +71,7 @@ export const uniqueSubgraphNodeIds = {
mode: 0
},
{
id: 11,
id: asNodeId(11),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -79,7 +80,7 @@ export const uniqueSubgraphNodeIds = {
mode: 0
},
{
id: 12,
id: asNodeId(12),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -91,9 +92,9 @@ export const uniqueSubgraphNodeIds = {
links: [
{
id: 1,
origin_id: 10,
origin_id: asNodeId(10),
origin_slot: 0,
target_id: 11,
target_id: asNodeId(11),
target_slot: 0,
type: 'number'
}
@@ -112,14 +113,14 @@ export const uniqueSubgraphNodeIds = {
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputNode: { id: asNodeId(-10), bounding: [10, 100, 150, 126] },
outputNode: { id: asNodeId(-20), bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 21, name: 'prompt' }],
widgets: [{ id: asNodeId(21), name: 'prompt' }],
nodes: [
{
id: 20,
id: asNodeId(20),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -128,7 +129,7 @@ export const uniqueSubgraphNodeIds = {
mode: 0
},
{
id: 21,
id: asNodeId(21),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -137,7 +138,7 @@ export const uniqueSubgraphNodeIds = {
mode: 0
},
{
id: 22,
id: asNodeId(22),
type: 'dummy',
pos: [0, 0],
size: [100, 50],
@@ -149,9 +150,9 @@ export const uniqueSubgraphNodeIds = {
links: [
{
id: 2,
origin_id: 20,
origin_id: asNodeId(20),
origin_slot: 0,
target_id: 22,
target_id: asNodeId(22),
target_slot: 0,
type: 'string'
}

View File

@@ -1,10 +1,12 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
UNASSIGNED_NODE_ID,
SUBGRAPH_INPUT_NODE_ID,
SUBGRAPH_OUTPUT_NODE_ID,
isFloatingNodeId
} from '@/types/nodeId'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import type { LinkConnectorEventMap } from '@/lib/litegraph/src/infrastructure/LinkConnectorEventMap'
import type {
@@ -37,13 +39,13 @@ export class FloatingRenderLink implements RenderLink {
readonly fromDirection: LinkDirection
readonly fromSlotIndex: SlotIndex
readonly outputNodeId: NodeId = -1
readonly outputNodeId: NodeId = UNASSIGNED_NODE_ID
readonly outputNode?: LGraphNode
readonly outputSlot?: INodeOutputSlot
readonly outputIndex: number = -1
readonly outputPos?: Point
readonly inputNodeId: NodeId = -1
readonly inputNodeId: NodeId = UNASSIGNED_NODE_ID
readonly inputNode?: LGraphNode
readonly inputSlot?: INodeInputSlot
readonly inputIndex: number = -1
@@ -63,7 +65,7 @@ export class FloatingRenderLink implements RenderLink {
target_slot: inputIndex
} = link
if (outputNodeId !== -1) {
if (!isFloatingNodeId(outputNodeId)) {
// Output connected
const outputNode = network.getNodeById(outputNodeId) ?? undefined
if (!outputNode)
@@ -175,7 +177,7 @@ export class FloatingRenderLink implements RenderLink {
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_INPUT_ID
floatingLink.origin_id = SUBGRAPH_INPUT_NODE_ID
floatingLink.origin_slot = input.parent.slots.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
@@ -188,7 +190,7 @@ export class FloatingRenderLink implements RenderLink {
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
floatingLink.origin_id = SUBGRAPH_OUTPUT_NODE_ID
floatingLink.origin_slot = output.parent.slots.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)

View File

@@ -8,6 +8,7 @@ import type {
ISlotType
} from '@/lib/litegraph/src/litegraph'
import {
asNodeId,
LGraph,
LGraphNode,
LLink,
@@ -46,7 +47,8 @@ const test = baseTest.extend<TestContext>({
reroutes,
floatingLinks,
getLink: graph.getLink.bind(graph),
getNodeById: (id: number) => graph.getNodeById(id),
getNodeById: graph.getNodeById.bind(graph),
getNodeByRawId: graph.getNodeByRawId.bind(graph),
addFloatingLink: (link: LLink) => {
floatingLinks.set(link.id, link)
return link
@@ -74,7 +76,7 @@ const test = baseTest.extend<TestContext>({
createTestNode: async ({ network }, use) => {
await use((id: number): LGraphNode => {
const node = new LGraphNode('test')
node.id = id
node.id = asNodeId(id)
network.add(node)
return node
})
@@ -87,7 +89,14 @@ const test = baseTest.extend<TestContext>({
targetId: number,
slotType: ISlotType = 'number'
): LLink => {
const link = new LLink(id, slotType, sourceId, 0, targetId, 0)
const link = new LLink(
id,
slotType,
asNodeId(sourceId),
0,
asNodeId(targetId),
0
)
network.links.set(link.id, link)
return link
}
@@ -121,7 +130,7 @@ describe('LinkConnector', () => {
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
const link = new LLink(1, slotType, asNodeId(1), 0, asNodeId(2), 0)
network.links.set(link.id, link)
targetNode.inputs[0].link = link.id
@@ -158,7 +167,7 @@ describe('LinkConnector', () => {
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(1, slotType, 1, 0, 2, 0)
const link = new LLink(1, slotType, asNodeId(1), 0, asNodeId(2), 0)
network.links.set(link.id, link)
sourceNode.outputs[0].links = [link.id]
@@ -261,7 +270,7 @@ describe('LinkConnector', () => {
connector.state.multi = true
connector.state.draggingExistingLinks = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
const link = new LLink(1, 'number', asNodeId(1), 0, asNodeId(2), 0)
link._dragging = true
connector.inputLinks.push(link)
@@ -302,7 +311,7 @@ describe('LinkConnector', () => {
fromPos: [0, 0],
fromDirection: LinkDirection.RIGHT,
toType: 'input',
link: new LLink(1, 'number', 1, 0, 2, 0)
link: new LLink(1, 'number', asNodeId(1), 0, asNodeId(2), 0)
} as MovingInputLink
connector.events.dispatch('input-moved', mockRenderLink)
@@ -319,7 +328,7 @@ describe('LinkConnector', () => {
connector.state.connectingTo = 'input'
connector.state.multi = true
const link = new LLink(1, 'number', 1, 0, 2, 0)
const link = new LLink(1, 'number', asNodeId(1), 0, asNodeId(2), 0)
connector.inputLinks.push(link)
const exported = connector.export(network)

Some files were not shown because too many files have changed in this diff Show More