Convert Links to ES6 Map & LLink Serialisation (#246)

* Fix intermittent links bug - graph.links Map()

Replaces graph.links with Map()
Adds a Proxy to provide for...in and indexer access
Temporarily uses merged Map+Record type, to ease downstream migration

* nit - Remove redundant code

* nit - Remove redundant null checks

* Add Serializable interface, used in LLink

Allows LLink to be serialised as an object rather than an array, bringing it inline with the rest of LiteGraph.
This commit is contained in:
filtered
2024-11-01 12:53:00 +11:00
committed by GitHub
parent eaaa0a4c39
commit 020c912a8d
6 changed files with 200 additions and 102 deletions

View File

@@ -6,6 +6,7 @@ import { LGraphCanvas } from "./LGraphCanvas"
import { LGraphGroup } from "./LGraphGroup"
import { type NodeId, LGraphNode } from "./LGraphNode"
import { type LinkId, LLink, type SerialisedLLinkArray } from "./LLink"
import { MapProxyHandler } from "./MapProxyHandler"
interface IGraphInput {
name: string
@@ -34,7 +35,20 @@ export class LGraph {
static STATUS_RUNNING = 2
_version: number
links: Record<LinkId, LLink>
/** The backing store for links. Keys are wrapped in String() */
_links: Map<LinkId, LLink> = new Map()
/**
* Indexed property access is deprecated.
* Backwards compatibility with a Proxy has been added, but will eventually be removed.
*
* Use {@link Map} methods:
* ```
* const linkId = 123
* const link = graph.links.get(linkId)
* // Deprecated: const link = graph.links[linkId]
* ```
*/
links: Map<LinkId, LLink> & Record<LinkId, LLink>
list_of_graphcanvas?: LGraphCanvas[]
status: number
last_node_id: number
@@ -101,11 +115,18 @@ export class LGraph {
constructor(o?: ISerialisedGraph) {
if (LiteGraph.debug) console.log("Graph created")
/** @see MapProxyHandler */
const links = this._links
MapProxyHandler.bindAllMethods(links)
const handler = new MapProxyHandler<LLink>()
this.links = new Proxy(links, handler) as Map<LinkId, LLink> & Record<LinkId, LLink>
this.list_of_graphcanvas = null
this.clear()
if (o) this.configure(o)
}
// TODO: Remove
//used to know which types of connections support this graph (some graphs do not allow certain types)
getSupportedTypes(): string[] {
@@ -140,9 +161,6 @@ export class LGraph {
//other scene stuff
this._groups = []
//links
this.links = {} //container with all the links
//iterations
this.iteration = 0
@@ -416,7 +434,7 @@ export class LGraph {
//for every connection
for (let j = 0; j < output.links.length; j++) {
const link_id = output.links[j]
const link = this.links[link_id]
const link = this._links.get(link_id)
if (!link) continue
//already visited link (ignore it)
@@ -1126,8 +1144,7 @@ export class LGraph {
* clears the triggered slot animation in all links (stop visual animation)
*/
clearTriggeredSlots(): void {
for (const i in this.links) {
const link_info = this.links[i]
for (const link_info of this._links.values()) {
if (!link_info) continue
if (link_info._last_time)
@@ -1150,7 +1167,7 @@ export class LGraph {
* @param {Number} link_id
*/
removeLink(link_id: LinkId): void {
const link = this.links[link_id]
const link = this._links.get(link_id)
if (!link) return
const node = this.getNodeById(link.target_id)
@@ -1170,22 +1187,7 @@ export class LGraph {
//pack link info into a non-verbose format
const links: SerialisedLLinkArray[] = []
for (const linkId in this.links) {
let link = this.links[linkId]
if (!link.serialize) {
//weird bug I havent solved yet
console.warn(
"weird LLink bug, link info is not a LLink but a regular object"
)
// @ts-expect-error Refactor this shallow copy or add static factory
const link2 = new LLink()
for (const j in link) {
link2[j] = link[j]
}
this.links[linkId] = link2
link = link2
}
for (const link of this._links.values()) {
links.push(link.serialize())
}
@@ -1224,25 +1226,17 @@ export class LGraph {
// LEGACY: This was changed from constructor === Array
//decode links info (they are very verbose)
if (Array.isArray(data.links)) {
const links: LLink[] = []
this._links.clear()
for (const link_data of data.links) {
//weird bug
if (!link_data) {
console.warn("serialized graph link data contains errors, skipping.")
continue
}
// @ts-expect-error Refactor this shallow copy or add static factory
const link = new LLink()
link.configure(link_data)
links[link.id] = link
const link = LLink.createFromArray(link_data)
this._links.set(link.id, link)
}
data.links = links
}
//copy all stored fields
for (const i in data) {
//links must be accepted
if (i == "nodes" || i == "groups")
if (i == "nodes" || i == "groups" || i == "links")
continue
this[i] = data[i]
}

View File

@@ -1812,7 +1812,7 @@ export class LGraphCanvas {
this.connecting_links = []
for (const linkId of output.links) {
const link = this.graph.links[linkId]
const link = this.graph._links.get(linkId)
const slot = link.target_slot
const linked_node = this.graph._nodes_by_id[link.target_id]
const input = linked_node.inputs[slot]
@@ -1887,7 +1887,7 @@ export class LGraphCanvas {
if (input.link !== null) {
//before disconnecting
const link_info = this.graph.links[input.link]
const link_info = this.graph._links.get(input.link)
const slot = link_info.origin_slot
const linked_node = this.graph._nodes_by_id[link_info.origin_id]
if (LiteGraph.click_do_break_link_to || (LiteGraph.ctrl_alt_click_do_break_link && e.ctrlKey && e.altKey && !e.shiftKey)) {
@@ -3020,7 +3020,7 @@ export class LGraphCanvas {
const input = node.inputs[j]
if (!input || input.link == null) continue
const link_info = this.graph.links[input.link]
const link_info = this.graph._links.get(input.link)
if (!link_info) continue
const target_node = this.graph.getNodeById(link_info.origin_id)
@@ -3354,8 +3354,8 @@ export class LGraphCanvas {
//autoconnect when possible (very basic, only takes into account first input-output)
if (node.inputs?.length && node.outputs && node.outputs.length && LiteGraph.isValidConnection(node.inputs[0].type, node.outputs[0].type) && node.inputs[0].link && node.outputs[0].links && node.outputs[0].links.length) {
const input_link = node.graph.links[node.inputs[0].link]
const output_link = node.graph.links[node.outputs[0].links[0]]
const input_link = node.graph._links.get(node.inputs[0].link)
const output_link = node.graph._links.get(node.outputs[0].links[0])
const input_node = node.getInputNode(0)
const output_node = node.getOutputNodes(0)[0]
if (input_node && output_node)
@@ -4959,7 +4959,7 @@ export class LGraphCanvas {
if (!input || input.link == null) continue
const link_id = input.link
const link = this.graph.links[link_id]
const link = this.graph._links.get(link_id)
if (!link) continue
//find link info

View File

@@ -354,7 +354,7 @@ export class LGraphNode {
if (this.inputs) {
for (let i = 0; i < this.inputs.length; ++i) {
const input = this.inputs[i]
const link = this.graph ? this.graph.links[input.link] : null
const link = this.graph ? this.graph._links.get(input.link) : null
this.onConnectionsChange?.(NodeSlotType.INPUT, i, true, link, input)
this.onInputAdded?.(input)
}
@@ -367,7 +367,7 @@ export class LGraphNode {
continue
}
for (let j = 0; j < output.links.length; ++j) {
const link = this.graph ? this.graph.links[output.links[j]] : null
const link = this.graph ? this.graph._links.get(output.links[j]) : null
this.onConnectionsChange?.(NodeSlotType.OUTPUT, i, true, link, output)
}
this.onOutputAdded?.(output)
@@ -551,7 +551,7 @@ export class LGraphNode {
if (this.outputs[slot].links) {
for (let i = 0; i < this.outputs[slot].links.length; i++) {
const link_id = this.outputs[slot].links[i]
const link = this.graph.links[link_id]
const link = this.graph._links.get(link_id)
if (link)
link.data = data
}
@@ -575,7 +575,7 @@ export class LGraphNode {
if (this.outputs[slot].links) {
for (let i = 0; i < this.outputs[slot].links.length; i++) {
const link_id = this.outputs[slot].links[i]
this.graph.links[link_id].type = type
this.graph._links.get(link_id).type = type
}
}
}
@@ -592,7 +592,7 @@ export class LGraphNode {
if (slot >= this.inputs.length || this.inputs[slot].link == null) return
const link_id = this.inputs[slot].link
const link: LLink = this.graph.links[link_id]
const link = this.graph._links.get(link_id)
//bug: weird case but it happens sometimes
if (!link) return null
@@ -621,7 +621,7 @@ export class LGraphNode {
if (slot >= this.inputs.length || this.inputs[slot].link == null) return null
const link_id = this.inputs[slot].link
const link = this.graph.links[link_id]
const link = this.graph._links.get(link_id)
//bug: weird case but it happens sometimes
if (!link) return null
@@ -677,7 +677,7 @@ export class LGraphNode {
if (!this.inputs) return null
if (slot < this.inputs.length) {
const slot_info = this.inputs[slot]
return this.graph.links[slot_info.link]
return this.graph._links.get(slot_info.link)
}
return null
}
@@ -694,7 +694,7 @@ export class LGraphNode {
const input = this.inputs[slot]
if (!input || input.link === null) return null
const link_info = this.graph.links[input.link]
const link_info = this.graph._links.get(input.link)
if (!link_info) return null
return this.graph.getNodeById(link_info.origin_id)
@@ -713,7 +713,7 @@ export class LGraphNode {
for (let i = 0, l = this.inputs.length; i < l; ++i) {
const input_info = this.inputs[i]
if (name == input_info.name && input_info.link != null) {
const link = this.graph.links[input_info.link]
const link = this.graph._links.get(input_info.link)
if (link) return link.data
}
}
@@ -785,7 +785,7 @@ export class LGraphNode {
const r: LGraphNode[] = []
for (let i = 0; i < output.links.length; i++) {
const link_id = output.links[i]
const link = this.graph.links[link_id]
const link = this.graph._links.get(link_id)
if (link) {
const target_node = this.graph.getNodeById(link.target_id)
if (target_node) {
@@ -962,7 +962,7 @@ export class LGraphNode {
//to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph.links[links[k]]
const link_info = this.graph._links.get(id)
//not connected
if (!link_info) continue
@@ -1008,7 +1008,7 @@ export class LGraphNode {
//to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph.links[links[k]]
const link_info = this.graph._links.get(id)
//not connected
if (!link_info) continue
@@ -1112,7 +1112,7 @@ export class LGraphNode {
continue
const links = this.outputs[i].links
for (let j = 0; j < links.length; ++j) {
const link = this.graph.links[links[j]]
const link = this.graph._links.get(links[j])
if (!link) continue
link.origin_slot -= 1
@@ -1186,7 +1186,7 @@ export class LGraphNode {
for (let i = slot; i < this.inputs.length; ++i) {
if (!this.inputs[i]) continue
const link = this.graph.links[this.inputs[i].link]
const link = this.graph._links.get(this.inputs[i].link)
if (!link) continue
link.target_slot -= 1
@@ -1797,7 +1797,8 @@ export class LGraphNode {
// Allow legacy API support for searching target_slot by string, without mutating the input variables
let targetIndex: number
if (!this.graph) {
const graph = this.graph
if (!graph) {
//could be connected before adding it to a graph
//due to link ids being associated with graphs
console.log("Connect: Error, node doesn't belong to any graph. Nodes must be added first to a graph before connecting them.")
@@ -1817,7 +1818,7 @@ export class LGraphNode {
}
if (target_node && typeof target_node === "number") {
target_node = this.graph.getNodeById(target_node)
target_node = graph.getNodeById(target_node)
}
if (!target_node) throw "target node is null"
@@ -1869,7 +1870,7 @@ export class LGraphNode {
if (!LiteGraph.isValidConnection(output.type, input.type)) {
this.setDirtyCanvas(false, true)
// @ts-expect-error Unused param
if (changed) this.graph.connectionChange(this, link_info)
if (changed) graph.connectionChange(this, link_info)
return null
}
@@ -1881,13 +1882,13 @@ export class LGraphNode {
//if there is something already plugged there, disconnect
if (target_node.inputs[targetIndex]?.link != null) {
this.graph.beforeChange()
graph.beforeChange()
target_node.disconnectInput(targetIndex)
changed = true
}
if (output.links?.length) {
if (output.type === LiteGraph.EVENT && !LiteGraph.allow_multi_output_for_events) {
this.graph.beforeChange()
graph.beforeChange()
// @ts-expect-error Unused param
this.disconnectOutput(slot, false, { doProcessChange: false })
changed = true
@@ -1896,7 +1897,7 @@ export class LGraphNode {
const nextId = LiteGraph.use_uuids
? LiteGraph.uuidv4()
: ++this.graph.last_link_id
: ++graph.last_link_id
//create link class
link_info = new LLink(
@@ -1909,14 +1910,14 @@ export class LGraphNode {
)
//add to graph links list
this.graph.links[link_info.id] = link_info
graph._links.set(link_info.id, link_info)
//connect in output
output.links ??= []
output.links.push(link_info.id)
//connect in input
target_node.inputs[targetIndex].link = link_info.id
if (this.graph) this.graph._version++
graph._version++
//link_info has been created now, so its updated
this.onConnectionsChange?.(
@@ -1934,14 +1935,14 @@ export class LGraphNode {
link_info,
input
)
this.graph?.onNodeConnectionChange?.(
graph.onNodeConnectionChange?.(
NodeSlotType.INPUT,
target_node,
targetIndex,
this,
slot
)
this.graph?.onNodeConnectionChange?.(
graph.onNodeConnectionChange?.(
NodeSlotType.OUTPUT,
this,
slot,
@@ -1950,8 +1951,8 @@ export class LGraphNode {
)
this.setDirtyCanvas(false, true)
this.graph.afterChange()
this.graph.connectionChange(this)
graph.afterChange()
graph.connectionChange(this)
return link_info
}
@@ -1989,7 +1990,7 @@ export class LGraphNode {
for (let i = 0, l = output.links.length; i < l; i++) {
const link_id = output.links[i]
const link_info = graph.links[link_id]
const link_info = graph._links.get(link_id)
//is the link we are searching for...
if (link_info.target_id == target_node.id) {
@@ -1997,8 +1998,9 @@ export class LGraphNode {
const input = target_node.inputs[link_info.target_slot]
input.link = null //remove there
delete graph.links[link_id] //remove the link from the links pool //remove the link from the links pool
if (graph) graph._version++
//remove the link from the links pool
graph._links.delete(link_id)
graph._version++
//link_info hasn't been modified so its ok
target_node.onConnectionsChange?.(
@@ -2016,10 +2018,8 @@ export class LGraphNode {
output
)
// FIXME: Called twice.
graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot)
graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot)
break
}
}
@@ -2027,12 +2027,12 @@ export class LGraphNode {
else {
for (let i = 0, l = output.links.length; i < l; i++) {
const link_id = output.links[i]
const link_info = graph.links[link_id]
const link_info = graph._links.get(link_id)
//bug: it happens sometimes
if (!link_info) continue
target_node = graph.getNodeById(link_info.target_id)
if (graph) graph._version++
graph._version++
if (target_node) {
const input = target_node.inputs[link_info.target_slot]
@@ -2047,11 +2047,9 @@ export class LGraphNode {
link_info,
input
)
// FIXME: Called twice.
graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot)
}
//remove the link from the links pool
delete graph.links[link_id]
graph._links.delete(link_id)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
@@ -2060,8 +2058,8 @@ export class LGraphNode {
link_info,
output
)
graph?.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph?.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot)
graph.onNodeConnectionChange?.(NodeSlotType.OUTPUT, this, slot)
graph.onNodeConnectionChange?.(NodeSlotType.INPUT, target_node, link_info.target_slot)
}
output.links = null
}
@@ -2092,26 +2090,20 @@ export class LGraphNode {
}
const input = this.inputs[slot]
if (!input) {
return false
}
if (!input) return false
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
//remove other side
const link_info = this.graph.links[link_id]
const link_info = this.graph._links.get(link_id)
if (link_info) {
const target_node = this.graph.getNodeById(link_info.origin_id)
if (!target_node) {
return false
}
if (!target_node) return false
const output = target_node.outputs[link_info.origin_slot]
if (!(output?.links?.length > 0)) {
return false
}
if (!(output?.links?.length > 0)) return false
//search in the inputs list for this link
let i = 0
@@ -2122,7 +2114,7 @@ export class LGraphNode {
}
}
delete this.graph.links[link_id] //remove from the pool
this.graph._links.delete(link_id)
if (this.graph) this.graph._version++
this.onConnectionsChange?.(

View File

@@ -1,23 +1,24 @@
import type { CanvasColour, ISlotType } from "./interfaces"
import type { NodeId } from "./LGraphNode"
import type { Serialisable, SerialisableLLink } from "./types/serialisation"
export type LinkId = number | string
export type SerialisedLLinkArray = [LinkId, NodeId, number, NodeId, number, ISlotType]
//this is the class in charge of storing link information
export class LLink {
export class LLink implements Serialisable<SerialisableLLink> {
/** Link ID */
id?: LinkId
type?: ISlotType
id: LinkId
type: ISlotType
/** Output node ID */
origin_id?: NodeId
origin_id: NodeId
/** Output slot index */
origin_slot?: number
origin_slot: number
/** Input node ID */
target_id?: NodeId
target_id: NodeId
/** Input slot index */
target_slot?: number
target_slot: number
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
@@ -46,6 +47,20 @@ export class LLink {
this._pos = new Float32Array(2) //center
}
/** @deprecated Use {@link LLink.create} */
static createFromArray(data: SerialisedLLinkArray): LLink {
return new LLink(data[0], data[5], data[1], data[2], data[3], data[4])
}
/**
* LLink static factory: creates a new LLink from the provided data.
* @param data Serialised LLink data to create the link from
* @returns A new LLink
*/
static create(data: SerialisableLLink): LLink {
return new LLink(data.id, data.type, data.origin_id, data.origin_slot, data.target_id, data.target_slot)
}
configure(o: LLink | SerialisedLLinkArray) {
if (Array.isArray(o)) {
this.id = o[0]
@@ -64,6 +79,10 @@ export class LLink {
}
}
/**
* @deprecated Prefer {@link LLink.asSerialisable} (returns an object, not an array)
* @returns An array representing this LLink
*/
serialize(): SerialisedLLinkArray {
return [
this.id,
@@ -74,4 +93,16 @@ export class LLink {
this.type
]
}
asSerialisable(): SerialisableLLink {
const copy: SerialisableLLink = {
id: this.id,
origin_id: this.origin_id,
origin_slot: this.origin_slot,
target_id: this.target_id,
target_slot: this.target_slot,
type: this.type
}
return copy
}
}

55
src/MapProxyHandler.ts Normal file
View File

@@ -0,0 +1,55 @@
/** Temporary workaround until downstream consumers migrate to Map. A brittle wrapper with many flaws, but should be fine for simple maps using int indexes. */
export class MapProxyHandler<V> implements ProxyHandler<Map<number | string, V>> {
getOwnPropertyDescriptor(target: Map<number | string, V>, p: string | symbol): PropertyDescriptor | undefined {
const value = this.get(target, p)
if (value) return {
configurable: true,
enumerable: true,
value
}
}
has(target: Map<number | string, V>, p: string | symbol): boolean {
if (typeof p === "symbol") return false
const int = parseInt(p, 10)
return target.has(!isNaN(int) ? int : p)
}
ownKeys(target: Map<number | string, V>): ArrayLike<string | symbol> {
return [...target.keys()].map(x => String(x))
}
get(target: Map<number | string, V>, p: string | symbol): any {
// Workaround does not support link IDs of "values", "entries", "constructor", etc.
if (p in target) return Reflect.get(target, p, target)
if (typeof p === "symbol") return
const int = parseInt(p, 10)
return target.get(!isNaN(int) ? int : p)
}
set(target: Map<number | string, V>, p: string | symbol, newValue: any): boolean {
if (typeof p === "symbol") return false
const int = parseInt(p, 10)
target.set(!isNaN(int) ? int : p, newValue)
return true
}
deleteProperty(target: Map<number | string, V>, p: string | symbol): boolean {
return target.delete(p as number | string)
}
static bindAllMethods(map: Map<any, any>): void {
map.clear = map.clear.bind(map)
map.delete = map.delete.bind(map)
map.forEach = map.forEach.bind(map)
map.get = map.get.bind(map)
map.has = map.has.bind(map)
map.set = map.set.bind(map)
map.entries = map.entries.bind(map)
map.keys = map.keys.bind(map)
map.values = map.values.bind(map)
}
}

View File

@@ -1,4 +1,4 @@
import type { Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Rect, Size } from "@/interfaces"
import type { ISlotType, Dictionary, INodeFlags, INodeInputSlot, INodeOutputSlot, Point, Rect, Size } from "@/interfaces"
import type { LGraph } from "@/LGraph"
import type { IGraphGroupFlags, LGraphGroup } from "@/LGraphGroup"
import type { LGraphNode, NodeId } from "@/LGraphNode"
@@ -7,6 +7,18 @@ import type { LinkId, LLink } from "@/LLink"
import type { TWidgetValue } from "@/types/widgets"
import { RenderShape } from "./globalEnums"
/**
* An object that implements custom pre-serialization logic via {@link Serialisable.asSerialisable}.
*/
export interface Serialisable<SerialisableObject> {
/**
* Prepares this object for serialization.
* Creates a partial shallow copy of itself, with only the properties that should be serialised.
* @returns An object that can immediately be serialized to JSON.
*/
asSerialisable(): SerialisableObject
}
/** Serialised LGraphNode */
export interface ISerialisedNode {
title?: string
@@ -38,7 +50,7 @@ export type ISerialisedGraph<
last_link_id: LGraph["last_link_id"]
last_reroute_id?: LGraph["last_reroute_id"]
nodes: TNode[]
links: TLink[] | LLink[]
links: TLink[]
groups: TGroup[]
config: LGraph["config"]
version: typeof LiteGraph.VERSION
@@ -61,3 +73,17 @@ export interface IClipboardContents {
nodes?: ISerialisedNode[]
links?: TClipboardLink[]
}
export interface SerialisableLLink {
/** Link ID */
id: LinkId
/** Output node ID */
origin_id: NodeId
/** Output slot index */
origin_slot: number
/** Input node ID */
target_id: NodeId
/** Input slot index */
target_slot: number
/** Data type of the link */
type: ISlotType
}