mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
1930 lines
56 KiB
TypeScript
1930 lines
56 KiB
TypeScript
import type { DragAndScaleState } from "./DragAndScale"
|
|
import type { LGraphEventMap } from "./infrastructure/LGraphEventMap"
|
|
import type {
|
|
Dictionary,
|
|
IContextMenuValue,
|
|
LinkNetwork,
|
|
LinkSegment,
|
|
MethodNames,
|
|
OptionalProps,
|
|
Point,
|
|
Positionable,
|
|
} from "./interfaces"
|
|
import type {
|
|
ExportedSubgraph,
|
|
ISerialisedGraph,
|
|
ISerialisedNode,
|
|
Serialisable,
|
|
SerialisableGraph,
|
|
SerialisableReroute,
|
|
} from "./types/serialisation"
|
|
import type { UUID } from "@/utils/uuid"
|
|
|
|
import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from "@/constants"
|
|
import { createUuidv4, zeroUuid } from "@/utils/uuid"
|
|
|
|
import { CustomEventTarget } from "./infrastructure/CustomEventTarget"
|
|
import { LGraphCanvas } from "./LGraphCanvas"
|
|
import { LGraphGroup } from "./LGraphGroup"
|
|
import { LGraphNode, type NodeId } from "./LGraphNode"
|
|
import { LiteGraph, SubgraphNode } from "./litegraph"
|
|
import { type LinkId, LLink } from "./LLink"
|
|
import { MapProxyHandler } from "./MapProxyHandler"
|
|
import { alignOutsideContainer, alignToContainer, createBounds } from "./measure"
|
|
import { Reroute, type RerouteId } from "./Reroute"
|
|
import { stringOrEmpty } from "./strings"
|
|
import { type GraphOrSubgraph, Subgraph } from "./subgraph/Subgraph"
|
|
import { SubgraphInput } from "./subgraph/SubgraphInput"
|
|
import { SubgraphOutput } from "./subgraph/SubgraphOutput"
|
|
import { getBoundaryLinks, groupResolvedByOutput, mapSubgraphInputsAndLinks, mapSubgraphOutputsAndLinks, multiClone, splitPositionables } from "./subgraph/subgraphUtils"
|
|
import { Alignment, LGraphEventMode } from "./types/globalEnums"
|
|
import { getAllNestedItems } from "./utils/collections"
|
|
|
|
export interface LGraphState {
|
|
lastGroupId: number
|
|
lastNodeId: number
|
|
lastLinkId: number
|
|
lastRerouteId: number
|
|
}
|
|
|
|
type ParamsArray<T extends Record<any, any>, K extends MethodNames<T>> =
|
|
Parameters<T[K]>[1] extends undefined
|
|
? Parameters<T[K]> | Parameters<T[K]>[0]
|
|
: Parameters<T[K]>
|
|
|
|
/** Configuration used by {@link LGraph} `config`. */
|
|
export interface LGraphConfig {
|
|
/** @deprecated Legacy config - unused */
|
|
align_to_grid?: any
|
|
links_ontop?: any
|
|
}
|
|
|
|
export interface LGraphExtra extends Dictionary<unknown> {
|
|
reroutes?: SerialisableReroute[]
|
|
linkExtensions?: { id: number, parentId: number | undefined }[]
|
|
ds?: DragAndScaleState
|
|
}
|
|
|
|
export interface BaseLGraph {
|
|
/** The root graph. */
|
|
readonly rootGraph: LGraph
|
|
}
|
|
|
|
/**
|
|
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
|
* supported callbacks:
|
|
* + onNodeAdded: when a new node is added to the graph
|
|
* + onNodeRemoved: when a node inside this graph is removed
|
|
*/
|
|
export class LGraph implements LinkNetwork, BaseLGraph, Serialisable<SerialisableGraph> {
|
|
static serialisedSchemaVersion = 1 as const
|
|
|
|
static STATUS_STOPPED = 1
|
|
static STATUS_RUNNING = 2
|
|
|
|
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
|
static readonly ConfigureProperties = new Set([
|
|
"nodes",
|
|
"groups",
|
|
"links",
|
|
"state",
|
|
"reroutes",
|
|
"floatingLinks",
|
|
"id",
|
|
"subgraphs",
|
|
"definitions",
|
|
"inputs",
|
|
"outputs",
|
|
"widgets",
|
|
"inputNode",
|
|
"outputNode",
|
|
"extra",
|
|
])
|
|
|
|
id: UUID = zeroUuid
|
|
revision: number = 0
|
|
|
|
_version: number = -1
|
|
/** 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[] | null
|
|
status: number = LGraph.STATUS_STOPPED
|
|
|
|
state: LGraphState = {
|
|
lastGroupId: 0,
|
|
lastNodeId: 0,
|
|
lastLinkId: 0,
|
|
lastRerouteId: 0,
|
|
}
|
|
|
|
readonly events = new CustomEventTarget<LGraphEventMap>()
|
|
readonly _subgraphs: Map<UUID, Subgraph> = new Map()
|
|
|
|
_nodes: (LGraphNode | SubgraphNode)[] = []
|
|
_nodes_by_id: Record<NodeId, LGraphNode> = {}
|
|
_nodes_in_order: LGraphNode[] = []
|
|
_nodes_executable: LGraphNode[] | null = null
|
|
_groups: LGraphGroup[] = []
|
|
iteration: number = 0
|
|
globaltime: number = 0
|
|
/** @deprecated Unused */
|
|
runningtime: number = 0
|
|
fixedtime: number = 0
|
|
fixedtime_lapse: number = 0.01
|
|
elapsed_time: number = 0.01
|
|
last_update_time: number = 0
|
|
starttime: number = 0
|
|
catch_errors: boolean = true
|
|
execution_timer_id?: number | null
|
|
errors_in_execution?: boolean
|
|
/** @deprecated Unused */
|
|
execution_time!: number
|
|
_last_trigger_time?: number
|
|
filter?: string
|
|
/** Must contain serialisable values, e.g. primitive types */
|
|
config: LGraphConfig = {}
|
|
vars: Dictionary<unknown> = {}
|
|
nodes_executing: boolean[] = []
|
|
nodes_actioning: (string | boolean)[] = []
|
|
nodes_executedAction: string[] = []
|
|
extra: LGraphExtra = {}
|
|
|
|
/** @deprecated Deserialising a workflow sets this unused property. */
|
|
version?: number
|
|
|
|
/** @returns Whether the graph has no items */
|
|
get empty(): boolean {
|
|
return this._nodes.length + this._groups.length + this.reroutes.size === 0
|
|
}
|
|
|
|
/** @returns All items on the canvas that can be selected */
|
|
*positionableItems(): Generator<LGraphNode | LGraphGroup | Reroute> {
|
|
for (const node of this._nodes) yield node
|
|
for (const group of this._groups) yield group
|
|
for (const reroute of this.reroutes.values()) yield reroute
|
|
return
|
|
}
|
|
|
|
/** Internal only. Not required for serialisation; calculated on deserialise. */
|
|
#lastFloatingLinkId: number = 0
|
|
|
|
#floatingLinks: Map<LinkId, LLink> = new Map()
|
|
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
|
|
return this.#floatingLinks
|
|
}
|
|
|
|
#reroutes = new Map<RerouteId, Reroute>()
|
|
/** All reroutes in this graph. */
|
|
public get reroutes(): Map<RerouteId, Reroute> {
|
|
return this.#reroutes
|
|
}
|
|
|
|
get rootGraph(): LGraph {
|
|
return this
|
|
}
|
|
|
|
get isRootGraph(): boolean {
|
|
return this.rootGraph === this
|
|
}
|
|
|
|
/** @deprecated See {@link state}.{@link LGraphState.lastNodeId lastNodeId} */
|
|
get last_node_id() {
|
|
return this.state.lastNodeId
|
|
}
|
|
|
|
set last_node_id(value) {
|
|
this.state.lastNodeId = value
|
|
}
|
|
|
|
/** @deprecated See {@link state}.{@link LGraphState.lastLinkId lastLinkId} */
|
|
get last_link_id() {
|
|
return this.state.lastLinkId
|
|
}
|
|
|
|
set last_link_id(value) {
|
|
this.state.lastLinkId = value
|
|
}
|
|
|
|
onAfterStep?(): void
|
|
onBeforeStep?(): void
|
|
onPlayEvent?(): void
|
|
onStopEvent?(): void
|
|
onAfterExecute?(): void
|
|
onExecuteStep?(): void
|
|
onNodeAdded?(node: LGraphNode): void
|
|
onNodeRemoved?(node: LGraphNode): void
|
|
onTrigger?(action: string, param: unknown): void
|
|
onBeforeChange?(graph: LGraph, info?: LGraphNode): void
|
|
onAfterChange?(graph: LGraph, info?: LGraphNode | null): void
|
|
onConnectionChange?(node: LGraphNode): void
|
|
on_change?(graph: LGraph): void
|
|
onSerialize?(data: ISerialisedGraph | SerialisableGraph): void
|
|
onConfigure?(data: ISerialisedGraph | SerialisableGraph): void
|
|
onGetNodeMenuOptions?(options: (IContextMenuValue<unknown> | null)[], node: LGraphNode): void
|
|
|
|
private _input_nodes?: LGraphNode[]
|
|
|
|
/**
|
|
* See {@link LGraph}
|
|
* @param o data from previous serialization [optional]
|
|
*/
|
|
constructor(o?: ISerialisedGraph | SerialisableGraph) {
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Removes all nodes from this graph
|
|
*/
|
|
clear(): void {
|
|
this.stop()
|
|
this.status = LGraph.STATUS_STOPPED
|
|
|
|
this.id = zeroUuid
|
|
this.revision = 0
|
|
|
|
this.state = {
|
|
lastGroupId: 0,
|
|
lastNodeId: 0,
|
|
lastLinkId: 0,
|
|
lastRerouteId: 0,
|
|
}
|
|
|
|
// used to detect changes
|
|
this._version = -1
|
|
this._subgraphs.clear()
|
|
|
|
// safe clear
|
|
if (this._nodes) {
|
|
for (const _node of this._nodes) {
|
|
_node.onRemoved?.()
|
|
}
|
|
}
|
|
|
|
// nodes
|
|
this._nodes = []
|
|
this._nodes_by_id = {}
|
|
// nodes sorted in execution order
|
|
this._nodes_in_order = []
|
|
// nodes that contain onExecute sorted in execution order
|
|
this._nodes_executable = null
|
|
|
|
this._links.clear()
|
|
this.reroutes.clear()
|
|
this.#floatingLinks.clear()
|
|
|
|
this.#lastFloatingLinkId = 0
|
|
|
|
// other scene stuff
|
|
this._groups = []
|
|
|
|
// iterations
|
|
this.iteration = 0
|
|
|
|
// custom data
|
|
this.config = {}
|
|
this.vars = {}
|
|
// to store custom data
|
|
this.extra = {}
|
|
|
|
// timing
|
|
this.globaltime = 0
|
|
this.runningtime = 0
|
|
this.fixedtime = 0
|
|
this.fixedtime_lapse = 0.01
|
|
this.elapsed_time = 0.01
|
|
this.last_update_time = 0
|
|
this.starttime = 0
|
|
|
|
this.catch_errors = true
|
|
|
|
this.nodes_executing = []
|
|
this.nodes_actioning = []
|
|
this.nodes_executedAction = []
|
|
|
|
// notify canvas to redraw
|
|
this.change()
|
|
|
|
this.canvasAction(c => c.clear())
|
|
}
|
|
|
|
get subgraphs(): Map<UUID, Subgraph> {
|
|
return this.rootGraph._subgraphs
|
|
}
|
|
|
|
get nodes() {
|
|
return this._nodes
|
|
}
|
|
|
|
get groups() {
|
|
return this._groups
|
|
}
|
|
|
|
/**
|
|
* Attach Canvas to this graph
|
|
*/
|
|
attachCanvas(canvas: LGraphCanvas): void {
|
|
if (!(canvas instanceof LGraphCanvas)) {
|
|
throw new TypeError("attachCanvas expects an LGraphCanvas instance")
|
|
}
|
|
|
|
this.primaryCanvas = canvas
|
|
|
|
this.list_of_graphcanvas ??= []
|
|
if (!this.list_of_graphcanvas.includes(canvas)) {
|
|
this.list_of_graphcanvas.push(canvas)
|
|
}
|
|
|
|
if (canvas.graph === this) return
|
|
|
|
canvas.graph?.detachCanvas(canvas)
|
|
canvas.graph = this
|
|
canvas.subgraph = undefined
|
|
}
|
|
|
|
/**
|
|
* Detach Canvas from this graph
|
|
*/
|
|
detachCanvas(canvas: LGraphCanvas): void {
|
|
canvas.graph = null
|
|
const canvases = this.list_of_graphcanvas
|
|
if (canvases) {
|
|
const pos = canvases.indexOf(canvas)
|
|
if (pos !== -1) canvases.splice(pos, 1)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated Will be removed in 0.9
|
|
* Starts running this graph every interval milliseconds.
|
|
* @param interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate
|
|
*/
|
|
start(interval?: number): void {
|
|
if (this.status == LGraph.STATUS_RUNNING) return
|
|
this.status = LGraph.STATUS_RUNNING
|
|
|
|
this.onPlayEvent?.()
|
|
this.sendEventToAllNodes("onStart")
|
|
|
|
// launch
|
|
this.starttime = LiteGraph.getTime()
|
|
this.last_update_time = this.starttime
|
|
interval ||= 0
|
|
|
|
// execute once per frame
|
|
if (
|
|
interval == 0 &&
|
|
typeof window != "undefined" &&
|
|
window.requestAnimationFrame
|
|
) {
|
|
const on_frame = () => {
|
|
if (this.execution_timer_id != -1) return
|
|
|
|
window.requestAnimationFrame(on_frame)
|
|
this.onBeforeStep?.()
|
|
this.runStep(1, !this.catch_errors)
|
|
this.onAfterStep?.()
|
|
}
|
|
this.execution_timer_id = -1
|
|
on_frame()
|
|
} else {
|
|
// execute every 'interval' ms
|
|
this.execution_timer_id = setInterval(() => {
|
|
// execute
|
|
this.onBeforeStep?.()
|
|
this.runStep(1, !this.catch_errors)
|
|
this.onAfterStep?.()
|
|
}, interval)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @deprecated Will be removed in 0.9
|
|
* Stops the execution loop of the graph
|
|
*/
|
|
stop(): void {
|
|
if (this.status == LGraph.STATUS_STOPPED) return
|
|
|
|
this.status = LGraph.STATUS_STOPPED
|
|
|
|
this.onStopEvent?.()
|
|
|
|
if (this.execution_timer_id != null) {
|
|
if (this.execution_timer_id != -1) {
|
|
clearInterval(this.execution_timer_id)
|
|
}
|
|
this.execution_timer_id = null
|
|
}
|
|
|
|
this.sendEventToAllNodes("onStop")
|
|
}
|
|
|
|
/**
|
|
* Run N steps (cycles) of the graph
|
|
* @param num number of steps to run, default is 1
|
|
* @param do_not_catch_errors [optional] if you want to try/catch errors
|
|
* @param limit max number of nodes to execute (used to execute from start to a node)
|
|
*/
|
|
runStep(num: number, do_not_catch_errors: boolean, limit?: number): void {
|
|
num = num || 1
|
|
|
|
const start = LiteGraph.getTime()
|
|
this.globaltime = 0.001 * (start - this.starttime)
|
|
|
|
const nodes = this._nodes_executable || this._nodes
|
|
if (!nodes) return
|
|
|
|
limit = limit || nodes.length
|
|
|
|
if (do_not_catch_errors) {
|
|
// iterations
|
|
for (let i = 0; i < num; i++) {
|
|
for (let j = 0; j < limit; ++j) {
|
|
const node = nodes[j]
|
|
// FIXME: Looks like copy/paste broken logic - checks for "on", executes "do"
|
|
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
|
|
// wrap node.onExecute();
|
|
node.doExecute?.()
|
|
}
|
|
}
|
|
|
|
this.fixedtime += this.fixedtime_lapse
|
|
this.onExecuteStep?.()
|
|
}
|
|
|
|
this.onAfterExecute?.()
|
|
} else {
|
|
try {
|
|
// iterations
|
|
for (let i = 0; i < num; i++) {
|
|
for (let j = 0; j < limit; ++j) {
|
|
const node = nodes[j]
|
|
if (node.mode == LGraphEventMode.ALWAYS) {
|
|
node.onExecute?.()
|
|
}
|
|
}
|
|
|
|
this.fixedtime += this.fixedtime_lapse
|
|
this.onExecuteStep?.()
|
|
}
|
|
|
|
this.onAfterExecute?.()
|
|
this.errors_in_execution = false
|
|
} catch (error) {
|
|
this.errors_in_execution = true
|
|
if (LiteGraph.throw_errors) throw error
|
|
|
|
if (LiteGraph.debug) console.log("Error during execution:", error)
|
|
this.stop()
|
|
}
|
|
}
|
|
|
|
const now = LiteGraph.getTime()
|
|
let elapsed = now - start
|
|
if (elapsed == 0) elapsed = 1
|
|
|
|
this.execution_time = 0.001 * elapsed
|
|
this.globaltime += 0.001 * elapsed
|
|
this.iteration += 1
|
|
this.elapsed_time = (now - this.last_update_time) * 0.001
|
|
this.last_update_time = now
|
|
this.nodes_executing = []
|
|
this.nodes_actioning = []
|
|
this.nodes_executedAction = []
|
|
}
|
|
|
|
/**
|
|
* Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than
|
|
* nodes with only inputs.
|
|
*/
|
|
updateExecutionOrder(): void {
|
|
this._nodes_in_order = this.computeExecutionOrder(false)
|
|
this._nodes_executable = []
|
|
for (const node of this._nodes_in_order) {
|
|
if (node.onExecute) {
|
|
this._nodes_executable.push(node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// This is more internal, it computes the executable nodes in order and returns it
|
|
computeExecutionOrder(
|
|
only_onExecute: boolean,
|
|
set_level?: boolean,
|
|
): LGraphNode[] {
|
|
const L: LGraphNode[] = []
|
|
const S: LGraphNode[] = []
|
|
const M: Dictionary<LGraphNode> = {}
|
|
// to avoid repeating links
|
|
const visited_links: Record<NodeId, boolean> = {}
|
|
const remaining_links: Record<NodeId, number> = {}
|
|
|
|
// search for the nodes without inputs (starting nodes)
|
|
for (const node of this._nodes) {
|
|
if (only_onExecute && !node.onExecute) {
|
|
continue
|
|
}
|
|
|
|
// add to pending nodes
|
|
M[node.id] = node
|
|
|
|
// num of input connections
|
|
let num = 0
|
|
if (node.inputs) {
|
|
for (const input of node.inputs) {
|
|
if (input?.link != null) {
|
|
num += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
if (num == 0) {
|
|
// is a starting node
|
|
S.push(node)
|
|
if (set_level) node._level = 1
|
|
} else {
|
|
// num of input links
|
|
if (set_level) node._level = 0
|
|
remaining_links[node.id] = num
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
// get an starting node
|
|
const node = S.shift()
|
|
if (node === undefined) break
|
|
|
|
// add to ordered list
|
|
L.push(node)
|
|
// remove from the pending nodes
|
|
delete M[node.id]
|
|
|
|
if (!node.outputs) continue
|
|
|
|
// for every output
|
|
for (const output of node.outputs) {
|
|
// not connected
|
|
// TODO: Confirm functionality, clean condition
|
|
if (output?.links == null || output.links.length == 0)
|
|
continue
|
|
|
|
// for every connection
|
|
for (const link_id of output.links) {
|
|
const link = this._links.get(link_id)
|
|
if (!link) continue
|
|
|
|
// already visited link (ignore it)
|
|
if (visited_links[link.id]) continue
|
|
|
|
const target_node = this.getNodeById(link.target_id)
|
|
if (target_node == null) {
|
|
visited_links[link.id] = true
|
|
continue
|
|
}
|
|
|
|
if (set_level) {
|
|
node._level ??= 0
|
|
if (!target_node._level || target_node._level <= node._level) {
|
|
target_node._level = node._level + 1
|
|
}
|
|
}
|
|
|
|
// mark as visited
|
|
visited_links[link.id] = true
|
|
// reduce the number of links remaining
|
|
remaining_links[target_node.id] -= 1
|
|
|
|
// if no more links, then add to starters array
|
|
if (remaining_links[target_node.id] == 0) S.push(target_node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// the remaining ones (loops)
|
|
for (const i in M) {
|
|
L.push(M[i])
|
|
}
|
|
|
|
if (L.length != this._nodes.length && LiteGraph.debug)
|
|
console.warn("something went wrong, nodes missing")
|
|
|
|
/** Ensure type is set */
|
|
type OrderedLGraphNode = LGraphNode & { order: number }
|
|
|
|
/** Sets the order property of each provided node to its index in {@link nodes}. */
|
|
function setOrder(nodes: LGraphNode[]): asserts nodes is OrderedLGraphNode[] {
|
|
const l = nodes.length
|
|
for (let i = 0; i < l; ++i) {
|
|
nodes[i].order = i
|
|
}
|
|
}
|
|
|
|
// save order number in the node
|
|
setOrder(L)
|
|
|
|
// sort now by priority
|
|
L.sort(function (A, B) {
|
|
// @ts-expect-error ctor props
|
|
const Ap = A.constructor.priority || A.priority || 0
|
|
// @ts-expect-error ctor props
|
|
const Bp = B.constructor.priority || B.priority || 0
|
|
// if same priority, sort by order
|
|
|
|
return Ap == Bp
|
|
? A.order - B.order
|
|
: Ap - Bp
|
|
})
|
|
|
|
// save order number in the node, again...
|
|
setOrder(L)
|
|
|
|
return L
|
|
}
|
|
|
|
/**
|
|
* Positions every node in a more readable manner
|
|
*/
|
|
arrange(margin?: number, layout?: string): void {
|
|
margin = margin || 100
|
|
|
|
const nodes = this.computeExecutionOrder(false, true)
|
|
const columns: LGraphNode[][] = []
|
|
for (const node of nodes) {
|
|
const col = node._level || 1
|
|
columns[col] ||= []
|
|
columns[col].push(node)
|
|
}
|
|
|
|
let x = margin
|
|
|
|
for (const column of columns) {
|
|
if (!column) continue
|
|
|
|
let max_size = 100
|
|
let y = margin + LiteGraph.NODE_TITLE_HEIGHT
|
|
for (const node of column) {
|
|
node.pos[0] = layout == LiteGraph.VERTICAL_LAYOUT ? y : x
|
|
node.pos[1] = layout == LiteGraph.VERTICAL_LAYOUT ? x : y
|
|
const max_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 1 : 0
|
|
if (node.size[max_size_index] > max_size) {
|
|
max_size = node.size[max_size_index]
|
|
}
|
|
const node_size_index = layout == LiteGraph.VERTICAL_LAYOUT ? 0 : 1
|
|
y += node.size[node_size_index] + margin + LiteGraph.NODE_TITLE_HEIGHT
|
|
}
|
|
x += max_size + margin
|
|
}
|
|
|
|
this.setDirtyCanvas(true, true)
|
|
}
|
|
|
|
/**
|
|
* Returns the amount of time the graph has been running in milliseconds
|
|
* @returns number of milliseconds the graph has been running
|
|
*/
|
|
getTime(): number {
|
|
return this.globaltime
|
|
}
|
|
|
|
/**
|
|
* Returns the amount of time accumulated using the fixedtime_lapse var.
|
|
* This is used in context where the time increments should be constant
|
|
* @returns number of milliseconds the graph has been running
|
|
*/
|
|
getFixedTime(): number {
|
|
return this.fixedtime
|
|
}
|
|
|
|
/**
|
|
* Returns the amount of time it took to compute the latest iteration.
|
|
* Take into account that this number could be not correct
|
|
* if the nodes are using graphical actions
|
|
* @returns number of milliseconds it took the last cycle
|
|
*/
|
|
getElapsedTime(): number {
|
|
return this.elapsed_time
|
|
}
|
|
|
|
/**
|
|
* @deprecated Will be removed in 0.9
|
|
* Sends an event to all the nodes, useful to trigger stuff
|
|
* @param eventname the name of the event (function to be called)
|
|
* @param params parameters in array format
|
|
*/
|
|
sendEventToAllNodes(
|
|
eventname: string,
|
|
params?: object | object[],
|
|
mode?: LGraphEventMode,
|
|
): void {
|
|
mode = mode || LGraphEventMode.ALWAYS
|
|
|
|
const nodes = this._nodes_in_order || this._nodes
|
|
if (!nodes) return
|
|
|
|
for (const node of nodes) {
|
|
// @ts-expect-error deprecated
|
|
if (!node[eventname] || node.mode != mode) continue
|
|
if (params === undefined) {
|
|
// @ts-expect-error deprecated
|
|
node[eventname]()
|
|
} else if (params && params.constructor === Array) {
|
|
// @ts-expect-error deprecated
|
|
node[eventname].apply(node, params)
|
|
} else {
|
|
// @ts-expect-error deprecated
|
|
node[eventname](params)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs an action on every canvas registered to this graph.
|
|
* @param action Action to run for every canvas
|
|
*/
|
|
canvasAction(action: (canvas: LGraphCanvas) => void): void {
|
|
const canvases = this.list_of_graphcanvas
|
|
if (!canvases) return
|
|
for (const canvas of canvases) action(canvas)
|
|
}
|
|
|
|
/** @deprecated See {@link LGraph.canvasAction} */
|
|
sendActionToCanvas<T extends MethodNames<LGraphCanvas>>(
|
|
action: T,
|
|
params?: ParamsArray<LGraphCanvas, T>,
|
|
): void {
|
|
const { list_of_graphcanvas } = this
|
|
if (!list_of_graphcanvas) return
|
|
|
|
for (const c of list_of_graphcanvas) {
|
|
c[action]?.apply(c, params)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a new node instance to this graph
|
|
* @param node the instance of the node
|
|
*/
|
|
add(
|
|
node: LGraphNode | LGraphGroup,
|
|
skip_compute_order?: boolean,
|
|
): LGraphNode | null | undefined {
|
|
if (!node) return
|
|
const { state } = this
|
|
|
|
// Ensure created items are snapped
|
|
if (LiteGraph.alwaysSnapToGrid) {
|
|
const snapTo = this.getSnapToGridSize()
|
|
if (snapTo) node.snapToGrid(snapTo)
|
|
}
|
|
|
|
// LEGACY: This was changed from constructor === LGraphGroup
|
|
// groups
|
|
if (node instanceof LGraphGroup) {
|
|
// Assign group ID
|
|
if (node.id == null || node.id === -1) node.id = ++state.lastGroupId
|
|
if (node.id > state.lastGroupId) state.lastGroupId = node.id
|
|
|
|
this._groups.push(node)
|
|
this.setDirtyCanvas(true)
|
|
this.change()
|
|
node.graph = this
|
|
this._version++
|
|
return
|
|
}
|
|
|
|
// nodes
|
|
if (node.id != -1 && this._nodes_by_id[node.id] != null) {
|
|
console.warn(
|
|
"LiteGraph: there is already a node with this ID, changing it",
|
|
)
|
|
node.id = LiteGraph.use_uuids
|
|
? LiteGraph.uuidv4()
|
|
: ++state.lastNodeId
|
|
}
|
|
|
|
if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) {
|
|
throw "LiteGraph: max number of nodes in a graph reached"
|
|
}
|
|
|
|
// give him an id
|
|
if (LiteGraph.use_uuids) {
|
|
if (node.id == null || node.id == -1)
|
|
node.id = LiteGraph.uuidv4()
|
|
} else {
|
|
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
|
|
}
|
|
}
|
|
|
|
node.graph = this
|
|
this._version++
|
|
|
|
this._nodes.push(node)
|
|
this._nodes_by_id[node.id] = node
|
|
|
|
node.onAdded?.(this)
|
|
|
|
if (this.config.align_to_grid) node.alignToGrid()
|
|
|
|
if (!skip_compute_order) this.updateExecutionOrder()
|
|
|
|
this.onNodeAdded?.(node)
|
|
|
|
this.setDirtyCanvas(true)
|
|
this.change()
|
|
|
|
// to chain actions
|
|
return node
|
|
}
|
|
|
|
/**
|
|
* Removes a node from the graph
|
|
* @param node the instance of the node
|
|
*/
|
|
remove(node: LGraphNode | LGraphGroup): void {
|
|
// LEGACY: This was changed from constructor === LiteGraph.LGraphGroup
|
|
if (node instanceof LGraphGroup) {
|
|
this.canvasAction(c => c.deselect(node))
|
|
|
|
const index = this._groups.indexOf(node)
|
|
if (index != -1) {
|
|
this._groups.splice(index, 1)
|
|
}
|
|
node.graph = undefined
|
|
this._version++
|
|
this.setDirtyCanvas(true, true)
|
|
this.change()
|
|
return
|
|
}
|
|
|
|
// not found
|
|
if (this._nodes_by_id[node.id] == null) {
|
|
console.warn("LiteGraph: node not found", node)
|
|
return
|
|
}
|
|
// cannot be removed
|
|
if (node.ignore_remove) {
|
|
console.warn("LiteGraph: node cannot be removed", node)
|
|
return
|
|
}
|
|
|
|
// sure? - almost sure is wrong
|
|
this.beforeChange()
|
|
|
|
const { inputs, outputs } = node
|
|
|
|
// disconnect inputs
|
|
if (inputs) {
|
|
for (const [i, slot] of inputs.entries()) {
|
|
if (slot.link != null) node.disconnectInput(i, true)
|
|
}
|
|
}
|
|
|
|
// disconnect outputs
|
|
if (outputs) {
|
|
for (const [i, slot] of outputs.entries()) {
|
|
if (slot.links?.length) node.disconnectOutput(i)
|
|
}
|
|
}
|
|
|
|
// Floating links
|
|
for (const link of this.floatingLinks.values()) {
|
|
if (link.origin_id === node.id || link.target_id === node.id) {
|
|
this.removeFloatingLink(link)
|
|
}
|
|
}
|
|
|
|
// callback
|
|
node.onRemoved?.()
|
|
|
|
node.graph = null
|
|
this._version++
|
|
|
|
// remove from canvas render
|
|
const { list_of_graphcanvas } = this
|
|
if (list_of_graphcanvas) {
|
|
for (const canvas of list_of_graphcanvas) {
|
|
if (canvas.selected_nodes[node.id])
|
|
delete canvas.selected_nodes[node.id]
|
|
|
|
canvas.deselect(node)
|
|
}
|
|
}
|
|
|
|
// remove from containers
|
|
const pos = this._nodes.indexOf(node)
|
|
if (pos != -1) this._nodes.splice(pos, 1)
|
|
|
|
delete this._nodes_by_id[node.id]
|
|
|
|
this.onNodeRemoved?.(node)
|
|
|
|
// close panels
|
|
this.canvasAction(c => c.checkPanels())
|
|
|
|
this.setDirtyCanvas(true, true)
|
|
// sure? - almost sure is wrong
|
|
this.afterChange()
|
|
this.change()
|
|
|
|
this.updateExecutionOrder()
|
|
}
|
|
|
|
/**
|
|
* Returns a node by its id.
|
|
*/
|
|
getNodeById(id: NodeId | null | undefined): LGraphNode | null {
|
|
return id != null
|
|
? this._nodes_by_id[id]
|
|
: null
|
|
}
|
|
|
|
/**
|
|
* Returns a list of nodes that matches a class
|
|
* @param classObject the class itself (not an string)
|
|
* @returns a list with all the nodes of this type
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
findNodesByClass(classObject: Function, result?: LGraphNode[]): LGraphNode[] {
|
|
result = result || []
|
|
result.length = 0
|
|
const { _nodes } = this
|
|
for (const node of _nodes) {
|
|
if (node.constructor === classObject)
|
|
result.push(node)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns a list of nodes that matches a type
|
|
* @param type the name of the node type
|
|
* @returns a list with all the nodes of this type
|
|
*/
|
|
findNodesByType(type: string, result: LGraphNode[]): LGraphNode[] {
|
|
const matchType = type.toLowerCase()
|
|
result = result || []
|
|
result.length = 0
|
|
const { _nodes } = this
|
|
for (const node of _nodes) {
|
|
if (node.type?.toLowerCase() == matchType)
|
|
result.push(node)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns the first node that matches a name in its title
|
|
* @param title the name of the node to search
|
|
* @returns the node or null
|
|
*/
|
|
findNodeByTitle(title: string): LGraphNode | null {
|
|
const { _nodes } = this
|
|
for (const node of _nodes) {
|
|
if (node.title == title)
|
|
return node
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Returns a list of nodes that matches a name
|
|
* @param title the name of the node to search
|
|
* @returns a list with all the nodes with this name
|
|
*/
|
|
findNodesByTitle(title: string): LGraphNode[] {
|
|
const result: LGraphNode[] = []
|
|
const { _nodes } = this
|
|
for (const node of _nodes) {
|
|
if (node.title == title)
|
|
result.push(node)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns the top-most node in this position of the canvas
|
|
* @param x the x coordinate in canvas space
|
|
* @param y the y coordinate in canvas space
|
|
* @param nodeList a list with all the nodes to search from, by default is all the nodes in the graph
|
|
* @returns the node at this position or null
|
|
*/
|
|
getNodeOnPos(
|
|
x: number,
|
|
y: number,
|
|
nodeList?: LGraphNode[],
|
|
): LGraphNode | null {
|
|
const nodes = nodeList || this._nodes
|
|
let i = nodes.length
|
|
while (--i >= 0) {
|
|
const node = nodes[i]
|
|
if (node.isPointInside(x, y)) return node
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Returns the top-most group in that position
|
|
* @param x The x coordinate in canvas space
|
|
* @param y The y coordinate in canvas space
|
|
* @returns The group or null
|
|
*/
|
|
getGroupOnPos(x: number, y: number): LGraphGroup | undefined {
|
|
return this._groups.toReversed().find(g => g.isPointInside(x, y))
|
|
}
|
|
|
|
/**
|
|
* Returns the top-most group with a titlebar in the provided position.
|
|
* @param x The x coordinate in canvas space
|
|
* @param y The y coordinate in canvas space
|
|
* @returns The group or null
|
|
*/
|
|
getGroupTitlebarOnPos(x: number, y: number): LGraphGroup | undefined {
|
|
return this._groups.toReversed().find(g => g.isPointInTitlebar(x, y))
|
|
}
|
|
|
|
/**
|
|
* Finds a reroute a the given graph point
|
|
* @param x X co-ordinate in graph space
|
|
* @param y Y co-ordinate in graph space
|
|
* @returns The first reroute under the given co-ordinates, or undefined
|
|
*/
|
|
getRerouteOnPos(x: number, y: number, reroutes?: Iterable<Reroute>): Reroute | undefined {
|
|
for (const reroute of reroutes ?? this.reroutes.values()) {
|
|
if (reroute.containsPoint([x, y])) return reroute
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snaps the provided items to a grid.
|
|
*
|
|
* Item positions are reounded to the nearest multiple of {@link LiteGraph.CANVAS_GRID_SIZE}.
|
|
*
|
|
* When {@link LiteGraph.alwaysSnapToGrid} is enabled
|
|
* and the grid size is falsy, a default of 1 is used.
|
|
* @param items The items to be snapped to the grid
|
|
* @todo Currently only snaps nodes.
|
|
*/
|
|
snapToGrid(items: Set<Positionable>): void {
|
|
const snapTo = this.getSnapToGridSize()
|
|
if (!snapTo) return
|
|
|
|
for (const item of getAllNestedItems(items)) {
|
|
if (!item.pinned) item.snapToGrid(snapTo)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds the size of the grid that items should be snapped to when moved.
|
|
* @returns The size of the grid that items should be snapped to
|
|
*/
|
|
getSnapToGridSize(): number {
|
|
// Default to 1 when always snapping
|
|
return LiteGraph.alwaysSnapToGrid
|
|
? LiteGraph.CANVAS_GRID_SIZE || 1
|
|
: LiteGraph.CANVAS_GRID_SIZE
|
|
}
|
|
|
|
/**
|
|
* @deprecated Will be removed in 0.9
|
|
* Checks that the node type matches the node type registered,
|
|
* used when replacing a nodetype by a newer version during execution
|
|
* this replaces the ones using the old version with the new version
|
|
*/
|
|
checkNodeTypes() {
|
|
const { _nodes } = this
|
|
for (const [i, node] of _nodes.entries()) {
|
|
const ctor = LiteGraph.registered_node_types[node.type]
|
|
if (node.constructor == ctor) continue
|
|
|
|
console.log("node being replaced by newer version:", node.type)
|
|
const newnode = LiteGraph.createNode(node.type)
|
|
if (!newnode) continue
|
|
_nodes[i] = newnode
|
|
newnode.configure(node.serialize())
|
|
newnode.graph = this
|
|
this._nodes_by_id[newnode.id] = newnode
|
|
|
|
if (node.inputs) newnode.inputs = [...node.inputs]
|
|
if (node.outputs) newnode.outputs = [...node.outputs]
|
|
}
|
|
this.updateExecutionOrder()
|
|
}
|
|
|
|
// ********** GLOBALS *****************
|
|
trigger(action: string, param: unknown) {
|
|
this.onTrigger?.(action, param)
|
|
}
|
|
|
|
/** @todo Clean up - never implemented. */
|
|
triggerInput(name: string, value: any): void {
|
|
const nodes = this.findNodesByTitle(name)
|
|
for (const node of nodes) {
|
|
// @ts-expect-error
|
|
node.onTrigger(value)
|
|
}
|
|
}
|
|
|
|
/** @todo Clean up - never implemented. */
|
|
setCallback(name: string, func: any): void {
|
|
const nodes = this.findNodesByTitle(name)
|
|
for (const node of nodes) {
|
|
// @ts-expect-error
|
|
node.setTrigger(func)
|
|
}
|
|
}
|
|
|
|
// used for undo, called before any change is made to the graph
|
|
beforeChange(info?: LGraphNode): void {
|
|
this.onBeforeChange?.(this, info)
|
|
this.canvasAction(c => c.onBeforeChange?.(this))
|
|
}
|
|
|
|
// used to resend actions, called after any change is made to the graph
|
|
afterChange(info?: LGraphNode | null): void {
|
|
this.onAfterChange?.(this, info)
|
|
this.canvasAction(c => c.onAfterChange?.(this))
|
|
}
|
|
|
|
/**
|
|
* clears the triggered slot animation in all links (stop visual animation)
|
|
*/
|
|
clearTriggeredSlots(): void {
|
|
for (const link_info of this._links.values()) {
|
|
if (!link_info) continue
|
|
|
|
if (link_info._last_time) link_info._last_time = 0
|
|
}
|
|
}
|
|
|
|
/* Called when something visually changed (not the graph!) */
|
|
change(): void {
|
|
if (LiteGraph.debug) {
|
|
console.log("Graph changed")
|
|
}
|
|
this.canvasAction(c => c.setDirty(true, true))
|
|
this.on_change?.(this)
|
|
}
|
|
|
|
setDirtyCanvas(fg: boolean, bg?: boolean): void {
|
|
this.canvasAction(c => c.setDirty(fg, bg))
|
|
}
|
|
|
|
addFloatingLink(link: LLink): LLink {
|
|
if (link.id === -1) {
|
|
link.id = ++this.#lastFloatingLinkId
|
|
}
|
|
this.#floatingLinks.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]
|
|
if (slot) {
|
|
slot._floatingLinks ??= new Set()
|
|
slot._floatingLinks.add(link)
|
|
} else {
|
|
console.warn(`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`)
|
|
}
|
|
|
|
const reroutes = LLink.getReroutes(this, link)
|
|
for (const reroute of reroutes) {
|
|
reroute.floatingLinkIds.add(link.id)
|
|
}
|
|
return link
|
|
}
|
|
|
|
removeFloatingLink(link: LLink): void {
|
|
this.#floatingLinks.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]
|
|
if (slot) {
|
|
slot._floatingLinks?.delete(link)
|
|
}
|
|
|
|
const reroutes = LLink.getReroutes(this, link)
|
|
for (const reroute of reroutes) {
|
|
reroute.floatingLinkIds.delete(link.id)
|
|
if (reroute.floatingLinkIds.size === 0) {
|
|
delete reroute.floating
|
|
}
|
|
|
|
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds the link with the provided ID.
|
|
* @param id ID of link to find
|
|
* @returns The link with the provided {@link id}, otherwise `undefined`. Always returns `undefined` if `id` is nullish.
|
|
*/
|
|
getLink(id: null | undefined): undefined
|
|
getLink(id: LinkId | null | undefined): LLink | undefined
|
|
getLink(id: LinkId | null | undefined): LLink | undefined {
|
|
return id == null ? undefined : this._links.get(id)
|
|
}
|
|
|
|
/**
|
|
* Finds the reroute with the provided ID.
|
|
* @param id ID of reroute to find
|
|
* @returns The reroute with the provided {@link id}, otherwise `undefined`. Always returns `undefined` if `id` is nullish.
|
|
*/
|
|
getReroute(id: null | undefined): undefined
|
|
getReroute(id: RerouteId | null | undefined): Reroute | undefined
|
|
getReroute(id: RerouteId | null | undefined): Reroute | undefined {
|
|
return id == null ? undefined : this.reroutes.get(id)
|
|
}
|
|
|
|
/**
|
|
* Configures a reroute on the graph where ID is already known (probably deserialisation).
|
|
* Creates the object if it does not exist.
|
|
* @param serialisedReroute See {@link SerialisableReroute}
|
|
*/
|
|
setReroute({ id, parentId, pos, linkIds, floating }: OptionalProps<SerialisableReroute, "id">): Reroute {
|
|
id ??= ++this.state.lastRerouteId
|
|
if (id > this.state.lastRerouteId) this.state.lastRerouteId = id
|
|
|
|
const reroute = this.reroutes.get(id) ?? new Reroute(id, this)
|
|
reroute.update(parentId, pos, linkIds, floating)
|
|
this.reroutes.set(id, reroute)
|
|
return reroute
|
|
}
|
|
|
|
/**
|
|
* Creates a new reroute and adds it to the graph.
|
|
* @param pos Position in graph space
|
|
* @param before The existing link segment (reroute, link) that will be after this reroute,
|
|
* going from the node output to input.
|
|
* @returns The newly created reroute - typically ignored.
|
|
*/
|
|
createReroute(pos: Point, before: LinkSegment): Reroute {
|
|
const rerouteId = ++this.state.lastRerouteId
|
|
const linkIds = before instanceof Reroute
|
|
? before.linkIds
|
|
: [before.id]
|
|
const floatingLinkIds = before instanceof Reroute
|
|
? before.floatingLinkIds
|
|
: [before.id]
|
|
const reroute = new Reroute(rerouteId, this, pos, before.parentId, linkIds, floatingLinkIds)
|
|
this.reroutes.set(rerouteId, reroute)
|
|
for (const linkId of linkIds) {
|
|
const link = this._links.get(linkId)
|
|
if (!link) continue
|
|
if (link.parentId === before.parentId) link.parentId = rerouteId
|
|
|
|
const reroutes = LLink.getReroutes(this, link)
|
|
for (const x of reroutes.filter(x => x.parentId === before.parentId)) {
|
|
x.parentId = rerouteId
|
|
}
|
|
}
|
|
|
|
for (const linkId of floatingLinkIds) {
|
|
const link = this.floatingLinks.get(linkId)
|
|
if (!link) continue
|
|
if (link.parentId === before.parentId) link.parentId = rerouteId
|
|
|
|
const reroutes = LLink.getReroutes(this, link)
|
|
for (const x of reroutes.filter(x => x.parentId === before.parentId)) {
|
|
x.parentId = rerouteId
|
|
}
|
|
}
|
|
|
|
return reroute
|
|
}
|
|
|
|
/**
|
|
* Removes a reroute from the graph
|
|
* @param id ID of reroute to remove
|
|
*/
|
|
removeReroute(id: RerouteId): void {
|
|
const { reroutes } = this
|
|
const reroute = reroutes.get(id)
|
|
if (!reroute) return
|
|
|
|
this.canvasAction(c => c.deselect(reroute))
|
|
|
|
// Extract reroute from the reroute chain
|
|
const { parentId, linkIds, floatingLinkIds } = reroute
|
|
for (const reroute of reroutes.values()) {
|
|
if (reroute.parentId === id) reroute.parentId = parentId
|
|
}
|
|
|
|
for (const linkId of linkIds) {
|
|
const link = this._links.get(linkId)
|
|
if (link && link.parentId === id) link.parentId = parentId
|
|
}
|
|
|
|
for (const linkId of floatingLinkIds) {
|
|
const link = this.floatingLinks.get(linkId)
|
|
if (!link) {
|
|
console.warn(`Removed reroute had floating link ID that did not exist [${linkId}]`)
|
|
continue
|
|
}
|
|
|
|
// A floating link is a unique branch; if there is no parent reroute, or
|
|
// the parent reroute has any other links, remove this floating link.
|
|
const floatingReroutes = LLink.getReroutes(this, link)
|
|
const lastReroute = floatingReroutes.at(-1)
|
|
const secondLastReroute = floatingReroutes.at(-2)
|
|
|
|
if (reroute !== lastReroute) {
|
|
continue
|
|
} else if (secondLastReroute?.totalLinks !== 1) {
|
|
this.removeFloatingLink(link)
|
|
} else if (link.parentId === id) {
|
|
link.parentId = parentId
|
|
secondLastReroute.floating = reroute.floating
|
|
}
|
|
}
|
|
|
|
reroutes.delete(id)
|
|
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
|
|
// https://github.com/Comfy-Org/litegraph.js/issues/898
|
|
this.setDirtyCanvas(false, true)
|
|
}
|
|
|
|
/**
|
|
* Destroys a link
|
|
*/
|
|
removeLink(link_id: LinkId): void {
|
|
const link = this._links.get(link_id)
|
|
if (!link) return
|
|
|
|
const node = this.getNodeById(link.target_id)
|
|
node?.disconnectInput(link.target_slot, false)
|
|
|
|
link.disconnect(this)
|
|
}
|
|
|
|
/**
|
|
* Creates a new subgraph definition, and adds it to the graph.
|
|
* @param data Exported data (typically serialised) to configure the new subgraph with
|
|
* @returns The newly created subgraph definition.
|
|
*/
|
|
createSubgraph(data: ExportedSubgraph): Subgraph {
|
|
const { id } = data
|
|
|
|
const subgraph = new Subgraph(this.rootGraph, data)
|
|
this.subgraphs.set(id, subgraph)
|
|
|
|
// FE: Create node defs
|
|
this.rootGraph.events.dispatch("subgraph-created", { subgraph, data })
|
|
return subgraph
|
|
}
|
|
|
|
convertToSubgraph(items: Set<Positionable>): { subgraph: Subgraph, node: SubgraphNode } {
|
|
if (items.size === 0) throw new Error("Cannot convert to subgraph: nothing to convert")
|
|
const { state, revision, config } = this
|
|
|
|
const { boundaryLinks, boundaryFloatingLinks, internalLinks, boundaryInputLinks, boundaryOutputLinks } = getBoundaryLinks(this, items)
|
|
const { nodes, reroutes, groups } = splitPositionables(items)
|
|
|
|
const boundingRect = createBounds(items)
|
|
if (!boundingRect) throw new Error("Failed to create bounding rect for subgraph")
|
|
|
|
const resolvedInputLinks = boundaryInputLinks.map(x => x.resolve(this))
|
|
const resolvedOutputLinks = boundaryOutputLinks.map(x => x.resolve(this))
|
|
|
|
const clonedNodes = multiClone(nodes)
|
|
|
|
// Inputs, outputs, and links
|
|
const links = internalLinks.map(x => x.asSerialisable())
|
|
const inputs = mapSubgraphInputsAndLinks(resolvedInputLinks, links)
|
|
const outputs = mapSubgraphOutputsAndLinks(resolvedOutputLinks, links)
|
|
|
|
// Prepare subgraph data
|
|
const data = {
|
|
id: createUuidv4(),
|
|
name: "New Subgraph",
|
|
inputNode: {
|
|
id: SUBGRAPH_INPUT_ID,
|
|
bounding: [0, 0, 75, 100],
|
|
},
|
|
outputNode: {
|
|
id: SUBGRAPH_OUTPUT_ID,
|
|
bounding: [0, 0, 75, 100],
|
|
},
|
|
inputs,
|
|
outputs,
|
|
widgets: [],
|
|
version: LGraph.serialisedSchemaVersion,
|
|
state,
|
|
revision,
|
|
config,
|
|
links,
|
|
nodes: clonedNodes,
|
|
reroutes: structuredClone([...reroutes].map(reroute => reroute.asSerialisable())),
|
|
groups: structuredClone([...groups].map(group => group.serialize())),
|
|
} satisfies ExportedSubgraph
|
|
|
|
const subgraph = this.createSubgraph(data)
|
|
subgraph.configure(data)
|
|
|
|
// Position the subgraph input nodes
|
|
subgraph.inputNode.arrange()
|
|
subgraph.outputNode.arrange()
|
|
const { boundingRect: inputRect } = subgraph.inputNode
|
|
const { boundingRect: outputRect } = subgraph.outputNode
|
|
alignOutsideContainer(inputRect, Alignment.MidLeft, boundingRect, [50, 0])
|
|
alignOutsideContainer(outputRect, Alignment.MidRight, boundingRect, [50, 0])
|
|
|
|
// Remove items converted to subgraph
|
|
for (const resolved of resolvedInputLinks) resolved.inputNode?.disconnectInput(resolved.inputNode.inputs.indexOf(resolved.input!), true)
|
|
for (const resolved of resolvedOutputLinks) resolved.outputNode?.disconnectOutput(resolved.outputNode.outputs.indexOf(resolved.output!), resolved.inputNode)
|
|
|
|
for (const node of nodes) this.remove(node)
|
|
for (const reroute of reroutes) this.removeReroute(reroute.id)
|
|
for (const group of groups) this.remove(group)
|
|
|
|
this.rootGraph.events.dispatch("convert-to-subgraph", {
|
|
subgraph,
|
|
bounds: boundingRect,
|
|
exportedSubgraph: data,
|
|
boundaryLinks,
|
|
resolvedInputLinks,
|
|
resolvedOutputLinks,
|
|
boundaryFloatingLinks,
|
|
internalLinks,
|
|
})
|
|
|
|
// Create subgraph node object
|
|
const subgraphNode = LiteGraph.createNode(subgraph.id, subgraph.name, {
|
|
inputs: structuredClone(inputs),
|
|
outputs: structuredClone(outputs),
|
|
})
|
|
if (!subgraphNode) throw new Error("Failed to create subgraph node")
|
|
|
|
// Resize to inputs/outputs
|
|
subgraphNode.setSize(subgraphNode.computeSize())
|
|
|
|
// Center the subgraph node
|
|
alignToContainer(subgraphNode._posSize, Alignment.Centre | Alignment.Middle, boundingRect)
|
|
|
|
// Add the subgraph node to the graph
|
|
this.add(subgraphNode)
|
|
|
|
// Group matching input links
|
|
const groupedByOutput = groupResolvedByOutput(resolvedInputLinks)
|
|
|
|
// Reconnect input links in parent graph
|
|
let i = 0
|
|
for (const [, connections] of groupedByOutput.entries()) {
|
|
const [firstResolved, ...others] = connections
|
|
const { output, outputNode, link, subgraphInput } = firstResolved
|
|
|
|
// Special handling: Subgraph input node
|
|
i++
|
|
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
|
link.target_id = subgraphNode.id
|
|
link.target_slot = i - 1
|
|
if (subgraphInput instanceof SubgraphInput) {
|
|
subgraphInput.connect(subgraphNode.findInputSlotByType(link.type, true, true), subgraphNode, link.parentId)
|
|
} else {
|
|
throw new TypeError("Subgraph input node is not a SubgraphInput")
|
|
}
|
|
console.debug("Reconnect input links in parent graph", { ...link }, this.links.get(link.id), this.links.get(link.id) === link)
|
|
|
|
for (const resolved of others) {
|
|
resolved.link.disconnect(this)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!output || !outputNode) {
|
|
console.warn("Convert to Subgraph reconnect: Failed to resolve input link", connections[0])
|
|
continue
|
|
}
|
|
|
|
const input = subgraphNode.findInputSlotByType(link.type, true, true)
|
|
outputNode.connectSlots(
|
|
output,
|
|
subgraphNode,
|
|
input,
|
|
link.parentId,
|
|
)
|
|
}
|
|
|
|
// Group matching links
|
|
const outputsGroupedByOutput = groupResolvedByOutput(resolvedOutputLinks)
|
|
|
|
// Reconnect output links in parent graph
|
|
i = 0
|
|
for (const [, connections] of outputsGroupedByOutput.entries()) {
|
|
// Special handling: Subgraph output node
|
|
i++
|
|
for (const connection of connections) {
|
|
const { input, inputNode, link, subgraphOutput } = connection
|
|
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
|
link.origin_id = subgraphNode.id
|
|
link.origin_slot = i - 1
|
|
this.links.set(link.id, link)
|
|
if (subgraphOutput instanceof SubgraphOutput) {
|
|
subgraphOutput.connect(subgraphNode.findOutputSlotByType(link.type, true, true), subgraphNode, link.parentId)
|
|
} else {
|
|
throw new TypeError("Subgraph input node is not a SubgraphInput")
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!input || !inputNode) {
|
|
console.warn("Convert to Subgraph reconnect: Failed to resolve output link", connection)
|
|
continue
|
|
}
|
|
|
|
const output = subgraphNode.outputs[i - 1]
|
|
subgraphNode.connectSlots(
|
|
output,
|
|
inputNode,
|
|
input,
|
|
link.parentId,
|
|
)
|
|
}
|
|
}
|
|
|
|
return { subgraph, node: subgraphNode as SubgraphNode }
|
|
}
|
|
|
|
/**
|
|
* Resolve a path of subgraph node IDs into a list of subgraph nodes.
|
|
* Not intended to be run from subgraphs.
|
|
* @param nodeIds An ordered list of node IDs, from the root graph to the most nested subgraph node
|
|
* @returns An ordered list of nested subgraph nodes.
|
|
*/
|
|
resolveSubgraphIdPath(nodeIds: readonly NodeId[]): SubgraphNode[] {
|
|
const result: SubgraphNode[] = []
|
|
let currentGraph: GraphOrSubgraph = this.rootGraph
|
|
|
|
for (const nodeId of nodeIds) {
|
|
const node: LGraphNode | null = currentGraph.getNodeById(nodeId)
|
|
if (!node) throw new Error(`Node [${nodeId}] not found. ID Path: ${nodeIds.join(":")}`)
|
|
if (!node.isSubgraphNode()) throw new Error(`Node [${nodeId}] is not a SubgraphNode. ID Path: ${nodeIds.join(":")}`)
|
|
|
|
result.push(node)
|
|
currentGraph = node.subgraph
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Creates a Object containing all the info about this graph, it can be serialized
|
|
* @deprecated Use {@link asSerialisable}, which returns the newer schema version.
|
|
* @returns value of the node
|
|
*/
|
|
serialize(option?: { sortNodes: boolean }): ISerialisedGraph {
|
|
const { config, state, groups, nodes, reroutes, extra, floatingLinks, definitions } = this.asSerialisable(option)
|
|
const linkArray = [...this._links.values()]
|
|
const links = linkArray.map(x => x.serialize())
|
|
|
|
if (reroutes?.length) {
|
|
// Link parent IDs cannot go in 0.4 schema arrays
|
|
extra.linkExtensions = linkArray
|
|
.filter(x => x.parentId !== undefined)
|
|
.map(x => ({ id: x.id, parentId: x.parentId }))
|
|
}
|
|
|
|
extra.reroutes = reroutes?.length ? reroutes : undefined
|
|
return {
|
|
id: this.id,
|
|
revision: this.revision,
|
|
last_node_id: state.lastNodeId,
|
|
last_link_id: state.lastLinkId,
|
|
nodes,
|
|
links,
|
|
floatingLinks,
|
|
groups,
|
|
definitions,
|
|
config,
|
|
extra,
|
|
version: LiteGraph.VERSION,
|
|
}
|
|
}
|
|
|
|
/** @returns The drag and scale state of the first attached canvas, otherwise `undefined`. */
|
|
#getDragAndScale(): DragAndScaleState | undefined {
|
|
const ds = this.list_of_graphcanvas?.at(0)?.ds
|
|
if (ds) return { scale: ds.scale, offset: ds.offset }
|
|
}
|
|
|
|
/**
|
|
* Prepares a shallow copy of this object for immediate serialisation or structuredCloning.
|
|
* The return value should be discarded immediately.
|
|
* @param options Serialise options = currently `sortNodes: boolean`, whether to sort nodes by ID.
|
|
* @returns A shallow copy of parts of this graph, with shallow copies of its serialisable objects.
|
|
* Mutating the properties of the return object may result in changes to your graph.
|
|
* It is intended for use with {@link structuredClone} or {@link JSON.stringify}.
|
|
*/
|
|
asSerialisable(options?: { sortNodes: boolean }): SerialisableGraph & Required<Pick<SerialisableGraph, "nodes" | "groups" | "extra">> {
|
|
const { id, revision, config, state } = this
|
|
|
|
const nodeList = !LiteGraph.use_uuids && options?.sortNodes
|
|
// @ts-expect-error If LiteGraph.use_uuids is false, ids are numbers.
|
|
? [...this._nodes].sort((a, b) => a.id - b.id)
|
|
: this._nodes
|
|
|
|
const nodes = nodeList.map(node => node.serialize())
|
|
const groups = this._groups.map(x => x.serialize())
|
|
|
|
const links = this._links.size ? [...this._links.values()].map(x => x.asSerialisable()) : undefined
|
|
const floatingLinks = this.floatingLinks.size ? [...this.floatingLinks.values()].map(x => x.asSerialisable()) : undefined
|
|
const reroutes = this.reroutes.size ? [...this.reroutes.values()].map(x => x.asSerialisable()) : undefined
|
|
|
|
// Save scale and offset
|
|
const extra = { ...this.extra }
|
|
if (LiteGraph.saveViewportWithGraph) extra.ds = this.#getDragAndScale()
|
|
if (!extra.ds) delete extra.ds
|
|
|
|
const data: ReturnType<typeof this.asSerialisable> = {
|
|
id,
|
|
revision,
|
|
version: LGraph.serialisedSchemaVersion,
|
|
config,
|
|
state,
|
|
groups,
|
|
nodes,
|
|
links,
|
|
floatingLinks,
|
|
reroutes,
|
|
extra,
|
|
}
|
|
|
|
if (this.isRootGraph && this._subgraphs.size) {
|
|
data.definitions = { subgraphs: [...this._subgraphs.values()].map(x => x.asSerialisable()) }
|
|
}
|
|
|
|
this.onSerialize?.(data)
|
|
return data
|
|
}
|
|
|
|
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
|
|
const { id, extra } = data
|
|
|
|
// Create a new graph ID if none is provided
|
|
if (id) {
|
|
this.id = id
|
|
} else if (this.id === zeroUuid) {
|
|
this.id = createUuidv4()
|
|
}
|
|
|
|
// Extra
|
|
this.extra = extra ? structuredClone(extra) : {}
|
|
|
|
// Ensure auto-generated serialisation data is removed from extra
|
|
delete this.extra.linkExtensions
|
|
}
|
|
|
|
/**
|
|
* Configure a graph from a JSON string
|
|
* @param data The deserialised object to configure this graph from
|
|
* @param keep_old If `true`, the graph will not be cleared prior to
|
|
* adding the configuration.
|
|
*/
|
|
configure(
|
|
data: ISerialisedGraph | SerialisableGraph,
|
|
keep_old?: boolean,
|
|
): boolean | undefined {
|
|
const options: LGraphEventMap["configuring"] = {
|
|
data,
|
|
clearGraph: !keep_old,
|
|
}
|
|
const mayContinue = this.events.dispatch("configuring", options)
|
|
if (!mayContinue) return
|
|
|
|
try {
|
|
// TODO: Finish typing configure()
|
|
if (!data) return
|
|
if (options.clearGraph) this.clear()
|
|
|
|
this._configureBase(data)
|
|
|
|
let reroutes: SerialisableReroute[] | undefined
|
|
|
|
// TODO: Determine whether this should this fall back to 0.4.
|
|
if (data.version === 0.4) {
|
|
const { extra } = data
|
|
// Deprecated - old schema version, links are arrays
|
|
if (Array.isArray(data.links)) {
|
|
for (const linkData of data.links) {
|
|
const link = LLink.createFromArray(linkData)
|
|
this._links.set(link.id, link)
|
|
}
|
|
}
|
|
// #region `extra` embeds for v0.4
|
|
|
|
// LLink parentIds
|
|
if (Array.isArray(extra?.linkExtensions)) {
|
|
for (const linkEx of extra.linkExtensions) {
|
|
const link = this._links.get(linkEx.id)
|
|
if (link) link.parentId = linkEx.parentId
|
|
}
|
|
}
|
|
|
|
// Reroutes
|
|
reroutes = extra?.reroutes
|
|
|
|
// #endregion `extra` embeds for v0.4
|
|
} else {
|
|
// New schema - one version so far, no check required.
|
|
|
|
// State
|
|
if (data.state) {
|
|
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = data.state
|
|
const { state } = this
|
|
if (lastGroupId != null) state.lastGroupId = lastGroupId
|
|
if (lastLinkId != null) state.lastLinkId = lastLinkId
|
|
if (lastNodeId != null) state.lastNodeId = lastNodeId
|
|
if (lastRerouteId != null) state.lastRerouteId = lastRerouteId
|
|
}
|
|
|
|
// Links
|
|
if (Array.isArray(data.links)) {
|
|
for (const linkData of data.links) {
|
|
const link = LLink.create(linkData)
|
|
this._links.set(link.id, link)
|
|
}
|
|
}
|
|
|
|
reroutes = data.reroutes
|
|
}
|
|
|
|
// Reroutes
|
|
if (Array.isArray(reroutes)) {
|
|
for (const rerouteData of reroutes) {
|
|
this.setReroute(rerouteData)
|
|
}
|
|
}
|
|
|
|
const nodesData = data.nodes
|
|
|
|
// copy all stored fields
|
|
for (const i in data) {
|
|
if (LGraph.ConfigureProperties.has(i)) continue
|
|
|
|
// @ts-expect-error #574 Legacy property assignment
|
|
this[i] = data[i]
|
|
}
|
|
|
|
// Subgraph definitions
|
|
const subgraphs = data.definitions?.subgraphs
|
|
if (subgraphs) {
|
|
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
|
|
for (const subgraph of subgraphs) this.subgraphs.get(subgraph.id)?.configure(subgraph)
|
|
}
|
|
|
|
let error = false
|
|
const nodeDataMap = new Map<NodeId, ISerialisedNode>()
|
|
|
|
// create nodes
|
|
this._nodes = []
|
|
if (nodesData) {
|
|
for (const n_info of nodesData) {
|
|
// stored info
|
|
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
|
|
if (!node) {
|
|
if (LiteGraph.debug) console.log("Node not found or has errors:", n_info.type)
|
|
|
|
// in case of error we create a replacement node to avoid losing info
|
|
node = new LGraphNode("")
|
|
node.last_serialization = n_info
|
|
node.has_errors = true
|
|
error = true
|
|
// continue;
|
|
}
|
|
|
|
// id it or it will create a new id
|
|
node.id = n_info.id
|
|
// add before configure, otherwise configure cannot create links
|
|
this.add(node, true)
|
|
nodeDataMap.set(node.id, n_info)
|
|
}
|
|
|
|
// configure nodes afterwards so they can reach each other
|
|
for (const [id, nodeData] of nodeDataMap) {
|
|
this.getNodeById(id)?.configure(nodeData)
|
|
}
|
|
}
|
|
|
|
// Floating links
|
|
if (Array.isArray(data.floatingLinks)) {
|
|
for (const linkData of data.floatingLinks) {
|
|
const floatingLink = LLink.create(linkData)
|
|
this.addFloatingLink(floatingLink)
|
|
|
|
if (floatingLink.id > this.#lastFloatingLinkId) this.#lastFloatingLinkId = floatingLink.id
|
|
}
|
|
}
|
|
|
|
// Drop broken reroutes
|
|
for (const reroute of this.reroutes.values()) {
|
|
// Drop broken links, and ignore reroutes with no valid links
|
|
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
|
|
this.reroutes.delete(reroute.id)
|
|
}
|
|
}
|
|
|
|
// groups
|
|
this._groups.length = 0
|
|
const groupData = data.groups
|
|
if (groupData) {
|
|
for (const data of groupData) {
|
|
// TODO: Search/remove these global object refs
|
|
const group = new LiteGraph.LGraphGroup()
|
|
group.configure(data)
|
|
this.add(group)
|
|
}
|
|
}
|
|
|
|
this.updateExecutionOrder()
|
|
|
|
this.onConfigure?.(data)
|
|
this._version++
|
|
|
|
// Ensure the primary canvas is set to the correct graph
|
|
const { primaryCanvas } = this
|
|
const subgraphId = primaryCanvas?.subgraph?.id
|
|
if (subgraphId) {
|
|
const subgraph = this.subgraphs.get(subgraphId)
|
|
if (subgraph) {
|
|
primaryCanvas.setGraph(subgraph)
|
|
} else {
|
|
primaryCanvas.setGraph(this)
|
|
}
|
|
}
|
|
|
|
this.setDirtyCanvas(true, true)
|
|
return error
|
|
} finally {
|
|
this.events.dispatch("configured")
|
|
}
|
|
}
|
|
|
|
#canvas?: LGraphCanvas
|
|
get primaryCanvas(): LGraphCanvas | undefined {
|
|
return this.rootGraph.#canvas
|
|
}
|
|
|
|
set primaryCanvas(canvas: LGraphCanvas) {
|
|
this.rootGraph.#canvas = canvas
|
|
}
|
|
|
|
load(url: string | Blob | URL | File, callback: () => void) {
|
|
const that = this
|
|
|
|
// from file
|
|
if (url instanceof Blob || url instanceof File) {
|
|
const reader = new FileReader()
|
|
reader.addEventListener("load", function (event) {
|
|
const result = stringOrEmpty(event.target?.result)
|
|
const data = JSON.parse(result)
|
|
that.configure(data)
|
|
callback?.()
|
|
})
|
|
|
|
reader.readAsText(url)
|
|
return
|
|
}
|
|
|
|
// is a string, then an URL
|
|
const req = new XMLHttpRequest()
|
|
req.open("GET", url, true)
|
|
req.send(null)
|
|
req.addEventListener("load", function () {
|
|
if (req.status !== 200) {
|
|
console.error("Error loading graph:", req.status, req.response)
|
|
return
|
|
}
|
|
const data = JSON.parse(req.response)
|
|
that.configure(data)
|
|
callback?.()
|
|
})
|
|
req.addEventListener("error", (err) => {
|
|
console.error("Error loading graph:", err)
|
|
})
|
|
}
|
|
}
|