/** * The published ComfyUI custom-node API — the complete surface. * * Generated from src/platform/nodeApi. If a member is not here it does not * exist: do not call it, and punt as api-gap naming what is missing. * Reached from a converted pack as: * * import { comfy } from '/comfy/api/v2.js' */ // ─── backendHandle.ts ──────────────────────────────────────────── export interface BackendHandle { /** * Absolute URL for a backend route, honouring however the host is served — * a base path, a different port, a proxy. */ url(route: string): string /** * Absolute URL for a file the host serves, rather than an API route. * * Distinct from `url()` because that one addresses the API and prepends * `/api`, so a static path built through it produced `/api/extensions/…`, * which 404s. * * This is for a path the caller already knows absolutely. It is *not* the * way a pack should reach its own neighbouring files: the host serves those * from `/extensions//`, and that directory name is chosen when * the pack is installed and can be renamed, so it is not knowable from * source. `new URL('x.css', import.meta.url)` resolves against the module's * real location and stays correct. One pack ships two spellings of its own * directory with an `onerror` fallback between them, which is what guessing * costs. */ assetUrl(route: string): string /** * Identifies this frontend connection to a pack's own backend route. * Undefined until the backend establishes the connection; do not persist it. */ sessionId(): string | undefined /** * Fires when {@link sessionId} becomes a different value. * * A pack that keys ephemeral server-side work by session — a scratch * directory, a warmed model, a subscription — needs to know its old key is * dead. The id changes on the first connection and again whenever the socket * reconnects under a new identity, and the work filed under the previous one * is no longer addressable. * * The session is not the user, the workflow or the node. It does not survive * a reload, and storing it in any of those is how a pack ends up reading * another tab's scratch state. */ onSessionChanged( listener: (sessionId: string | undefined) => void ): Unsubscribe /** * Subscribes to a backend message. The name is whatever the backend emits; * `detail` is its payload, unparsed. */ on(event: string, listener: (detail: unknown) => void): Unsubscribe /** * Calls a backend route with the host's own credentials attached. * * `url()` only builds a string, so a pack calling `fetch()` on it sends an * unauthenticated request — fine on a local install, a 401 on a hosted one. * Packs ship their own Python routes and were reaching for `api.fetchApi` * precisely to inherit the session; this is that, and nothing more. * * The route is API-relative and must start with `/`, as `url()` requires. */ fetch(route: string, init?: RequestInit): Promise } // ─── chromeContributions.ts ────────────────────────────────────── export interface BadgeContribution { /** Namespaced, e.g. `Crystools.monitor`. Registering the same id twice throws. */ readonly id: string readonly text: string readonly label?: string readonly variant?: 'info' | 'warning' | 'error' /** An iconify or PrimeIcons class, e.g. `pi-chart-bar`. */ readonly icon?: string readonly tooltip?: string } /** What a pack keeps after contributing something to the chrome. */ export interface ChromeItemHandle { /** Changes what is shown. Only the fields given are replaced. */ update(changes: Partial>): void remove(): void } export interface ButtonContribution { readonly id: string readonly icon: string readonly label?: string readonly tooltip?: string /** * The click. The event is passed because packs branch on modifiers — one * opens its panel in a sized window on shift-click — and without it that * behaviour has nothing to read. */ run(event: MouseEvent): void } // ─── boundedFiles.ts ───────────────────────────────────────────── export interface FilePickOptions { readonly extensions?: readonly string[] readonly mimeTypes?: readonly string[] /** Maximum accepted file size. The host-wide ceiling is 16 MiB. */ readonly maxBytes: number } export interface FilePickManyOptions extends FilePickOptions { /** Maximum number of selected files. The host-wide ceiling is 50. */ readonly maxFiles: number /** Maximum aggregate payload. The host-wide ceiling is 256 MiB. */ readonly maxTotalBytes: number } export interface PickedFileData { /** Basename only; no host path is exposed. */ readonly name: string readonly type: string readonly bytes: Uint8Array } export interface FileDownloadOptions { /** Safe basename only. */ readonly name: string readonly mimeType: string /** At most 16 MiB. */ readonly bytes: Uint8Array } export interface FilesHandle { /** Opens one explicit host file picker; cancellation resolves undefined. */ pick(options: FilePickOptions): Promise /** Opens one bounded multi-file picker; cancellation resolves an empty list. */ pickMany(options: FilePickManyOptions): Promise /** Asks the host to download one bounded in-memory file. */ download(options: FileDownloadOptions): Promise } // ─── cryptoHandle.ts ───────────────────────────────────────────── export interface AesCbcEncryptOptions { readonly key: Uint8Array readonly iv: Uint8Array readonly plaintext: Uint8Array } export interface AesCbcDecryptOptions { readonly key: Uint8Array readonly iv: Uint8Array readonly ciphertext: Uint8Array } export interface HmacSha256Options { readonly key: Uint8Array readonly data: Uint8Array } export interface VerifyHmacSha256Options extends HmacSha256Options { readonly signature: Uint8Array } /** Fixed canonical primitives; no caller-selected algorithms or retained keys. */ export interface CryptoHandle { aesCbcEncrypt(options: AesCbcEncryptOptions): Promise aesCbcDecrypt(options: AesCbcDecryptOptions): Promise hmacSha256(options: HmacSha256Options): Promise verifyHmacSha256(options: VerifyHmacSha256Options): Promise } // ─── integrationsHandle.ts ─────────────────────────────────────── export interface OllamaListModelsOptions { /** Exact loopback Ollama origin or an `ollama://name` admin profile. */ readonly endpoint: string } export interface OllamaIntegrationHandle { listModels(options: OllamaListModelsOptions): Promise } /** Vendor pass-throughs have a weaker stability promise than generic APIs. */ export interface IntegrationsHandle { readonly ollama: OllamaIntegrationHandle } // ─── closedProxy.ts ────────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface PropSpec { get(target: TTarget): unknown set?(target: TTarget, value: unknown): void /** Appended to the error when a pack assigns to a read-only property. */ readonlyHint?: string } export interface HandleSpec { /** Used in errors and `Symbol.toStringTag`, e.g. 'node'. */ readonly kind: string readonly props: Readonly>> readonly methods?: Readonly< Record unknown> > /** * Methods that also need the handle's own id. * * A widget target is just the widget: it holds no reference back to its * node, by design, so a method that has to name a sibling cannot find one * from the target alone. Separate from `methods` so the common signature * stays two arguments. */ readonly idMethods?: Readonly< Record unknown> > /** * Props that remain readable after deletion. Identity only — an id or type is * still useful for logging and cleanup once the entity is gone. */ readonly identityProps?: readonly string[] } /** Present on every handle. Never throws, even when the entity is gone. */ export interface HandleCommon { readonly isDeleted: boolean } export interface HandleToken { readonly kind: string readonly id: string } // ─── comfyApi.ts ───────────────────────────────────────────────── export interface Comfy { /** * `major.minor`. Prefer `supports()` over comparing this — a capability * survives being backported or reordered across minors; a version comparison * does not. */ readonly version: string /** Breaking-change generation. Incremented only when something is removed. */ readonly major: number /** * Cheap, never throws. The supported way to branch. * * Answers whether this host can do something, under the grant it is running * with. It is not a permission request: asking does not obtain authority, and * a pack never enumerates capabilities to be allowed to run. */ supports(capability: string): boolean /** Asserts a capability, with an actionable error naming it. */ require(capability: string): void /** Every capability this host provides. */ capabilities(): readonly string[] /** * Pins to a specific major. * * A major stays available until it is announced for removal and withdrawn * through the normal phased deprecation process, so a pack written against * one keeps working across that period rather than breaking on a release. */ forMajor(major: number): Comfy /** * True when two handles refer to the same entity, whatever major, API * instance or graph scope produced them. * * `===` is only reliable for handles from the same instance, the same major * AND the same scope. Scope is the one most likely to catch a pack out: a * node reached through `comfy.graph` while it is on screen and the same node * reached through `graph.subgraphs()` or through a document-scoped * `onNodeChanged` come from different handle caches, so they are equal here * and not equal under `===`. Use this whenever a handle may have come from * another pack, from an event, or from a graph other than the visible one. */ sameEntity(a: unknown, b: unknown): boolean /** * Re-resolves a handle from any major or instance into one of this instance's * own. Returns `undefined` if it is not a handle, or its entity is gone. */ adopt(handle: unknown): NodeHandle | undefined readonly graph: GraphHandle /** Node definitions, and the replacement for `beforeRegisterNodeDef`. */ readonly defs: DefRegistry /** Declaring, reading and writing pack settings. */ readonly settings: SettingsHandle /** * Per-user persistent storage for documents the pack's users author — * templates, presets, saved prompts. Server-side, so it follows the user * between machines. */ readonly storage: StorageHandle /** Bounded, host-sampled hardware metrics. */ readonly system: SystemHandle /** The sanctioned slice of app chrome — sidebar tabs. */ readonly ui: UiHandle /** Host-owned facilities shared by widget implementations. */ readonly widgets: WidgetsHandle /** Bounded declarative locale catalogs rendered by host-native i18n. */ readonly localization: LocalizationHandle /** Commands, their keybindings, and notifications. */ readonly commands: CommandsHandle /** Backend URLs and messages, including a pack's own events. */ readonly backend: BackendHandle /** Loading a parsed workflow into a new active document. */ readonly workflow: WorkflowHandle /** Explicit, bounded host file selection and download. */ readonly files: FilesHandle /** Fixed host cryptographic primitives available to opaque-origin workers. */ readonly crypto: CryptoHandle /** Bounded vendor-specific facilities. */ readonly integrations: IntegrationsHandle /** * The editor is already mid-gesture — dragging a link, resizing a node, * dragging a widget. A pack running its own pointer gesture must stand down * while this is true. */ isInteracting(): boolean /** * Observes nodes being moved, under either renderer. * * For building an editing gesture — swap, insert-on-link, shake-to-detach. * A pack that moves nodes itself will see its own writes, so guard re-entry. */ onNodeMoved(listener: (event: NodeMoveEvent) => void): Unsubscribe /** * A drag finished; every node it moved. * * Where an editing gesture commits — swap the pair, insert into the link * under the cursor. **Nodes 2.0 only**: the legacy canvas renderer publishes * no drag lifecycle, so this never fires under it. */ onNodeDragEnd(listener: (nodes: readonly NodeHandle[]) => void): Unsubscribe /** * The view panned, zoomed or was resized. * * For keeping something anchored to a node in sync — ask * `node.getScreenRect()` again when this fires. Carries no payload: where a * node is belongs to the node, and the transform belongs to the renderer. */ onViewportChanged(listener: () => void): Unsubscribe /** * A node changed — its mode, title, colour or shape. * * For observing nodes the pack does not own. rgthree's relay polls every * 500ms and installs a `defineProperty` trap on `mode` because nothing * reports it; this is that signal. * * One stream rather than a subscription per node, deliberately: node * identity does not survive undo, reload or re-entering a subgraph, so * anything keyed by the object stops firing silently, and keying by id * instead never gets collected. Filter by `event.node.id`. * * Only fields the host tracks are reported. Position is not among them — it * changes per frame during a drag and is served by {@link onNodeMoved}. * * Reports the graph on screen unless `scope: 'document'` asks for the root * graph and every subgraph definition as well. A pack that computes from * other nodes wants `'document'`: a relay in a subgraph the user has * navigated away from otherwise stops recomputing while still asserting its * last answer. Each event names the graph it came from, and resolves its node * there — ids repeat across definitions, so `event.node.id` alone is not a * key. */ onNodeChanged( listener: (event: NodeChangeEvent) => void, options?: NodeChangeOptions ): Unsubscribe /** * The application has finished starting: canvas, settings and graph all * exist, and node definitions are registered. * * This is `registerExtension({ setup })`. A pack's module body is the `init` * half — it runs before definitions register — so anything that needs the * running app belongs here. Registering after the app has already started is * fine; the listener is called on the next microtask rather than dropped, * which is what makes this safe for a pack loaded lazily. * * Do not poll for the DOM instead. Several packs shipped a `waitForElements` * loop to paper over the missing hook, and a poll that outlives its target * is a leak that only shows up on someone else's machine. */ onReady(listener: () => void): Unsubscribe /** Starting a run, and knowing when one starts. */ queue: QueueHandle /** * The node the backend is executing, or `undefined` between runs. * * Packs tracked this from the raw `executing` message to badge the running * node or follow it with the view. */ executingNode(): NodeHandle | undefined /** Resolves a backend execution id, including a nested subgraph path. */ executionNode(id: string): NodeHandle | undefined /** Fires when {@link executingNode} changes, including to nothing. */ onExecutingNodeChanged( listener: (node: NodeHandle | undefined) => void ): Unsubscribe /** * A workflow finished loading, and the graph is the new one. * * This is `afterConfigureGraph`. Unlike {@link onReady} it fires again for * every workflow the user opens, which is what a pack re-attaching itself to * the document needs — `onReady` fires once and misses every later open. * * It also fires for undo, redo and a reload of the same document, because a * pack rebuilding state from the graph needs those too. The handle says * which of them happened: an id equal to the one from last time means this * document was rebuilt, not replaced. `undefined` when the host cannot name * a document, as when raw workflow data is loaded with no file behind it. */ onWorkflowLoaded( listener: (document: DocumentHandle | undefined) => void ): Unsubscribe /** * A document's editing session began. * * Where per-document state belongs. Fires for a tab opened in the * background too, so a pack that allocates here and releases in * {@link onDocumentClosed} stays balanced however the user moves around. */ onDocumentOpened(listener: (document: DocumentHandle) => void): Unsubscribe /** * A document became the one on screen. * * Distinct from opening: the user returning to a tab activates a document * that was already open, and its state is still valid. Anything tied to * *being visible* — a panel, a canvas overlay — belongs here. */ onDocumentActivated(listener: (document: DocumentHandle) => void): Unsubscribe /** * A document stopped being the one on screen, but is still open. * * Fires before the next document is activated, so a pack moving something * between them never sees two claiming the screen at once. */ onDocumentDeactivated( listener: (document: DocumentHandle) => void ): Unsubscribe /** * A document's editing session ended, however it ended — the user closing * the tab, a temporary workflow being deleted, or the host discarding a * background tab whose file changed on disk. * * Release everything keyed to it. The handle already reports `isDeleted`, * and carries the id so a pack can find what it stored; it will not describe * the document, because there is no longer one to describe. */ onDocumentClosed(listener: (document: DocumentHandle) => void): Unsubscribe } // ─── commandsHandle.ts ─────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface KeyCombo { readonly key: string readonly ctrl?: boolean readonly alt?: boolean readonly shift?: boolean readonly meta?: boolean } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface CommandDef { /** Namespaced, e.g. `MyPack.doTheThing`. Shared with core and every pack. */ readonly id: string /** * A function when the label depends on state — a toggle that reads "Follow * execution" and then "Stop following execution". It is read each time the * label is shown, so it must return quickly. */ readonly label: string | (() => string) readonly run: () => void | Promise /** Bound as a default, so a user's own binding still wins. */ readonly keybinding?: KeyCombo /** * Where the keybinding applies. Defaults to anywhere in the application. * * `'canvas'` limits it to the graph, so it will not fire while the user is * typing in a node's text widget or any other field. The host already * withholds combos a text input owns — every bare arrow, Ctrl+Left/Right, * Ctrl+A/C/V/X/Z — but a pack binding something it does not, say Ctrl+Up, * would otherwise fire mid-sentence. */ readonly scope?: 'canvas' } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface NotifyDef { readonly severity?: 'success' | 'info' | 'warn' | 'error' readonly summary: string readonly detail?: string /** Milliseconds. Omit for the host's default. */ readonly life?: number } export interface CommandsHandle { register(def: CommandDef): void notify(def: NotifyDef): void /** * Runs a command the host or another pack registered, by id. * * Packs reached into internals to do what a command already does — opening * the mask editor was `ComfyApp.copyToClipspace` plus `clipspace_return_node` * plus invoking `Comfy.MaskEditor.OpenMaskEditor` by hand. Commands are the * sanctioned action layer, so a pack can ask for the behaviour without the * host having to publish the machinery behind it. * * Rejects if no such command is registered — a pack naming a command that * has been renamed should hear about it rather than silently do nothing. */ run(id: string): Promise /** Whether a command exists, for a pack that offers an entry conditionally. */ has(id: string): boolean } // ─── defsRegistry.ts ───────────────────────────────────────────── /** * The read view of a node definition. Frozen and inert, like every read here. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeDef { readonly type: string readonly title: string readonly category: string readonly description: string readonly inputs: readonly Readonly<{ name: string type: string /** The translated caption core renders for this input, when it differs. */ localizedName?: string /** The declared choices for a COMBO input, in backend order. */ values?: readonly (string | number)[] /** * The input's declaration dict, verbatim from the backend. * * Same passthrough reasoning as `ExecutionResult.raw`: a pack declares its * own keys on its own Python input spec and reads them back here to drive * frontend behaviour, so discarding unrecognised keys breaks the pack * against its own data. Carries `default`, `min`, `max` and the like too. */ options: Readonly> }>[] readonly outputs: readonly Readonly<{ name: string type: string tooltip?: string }>[] readonly isOutputNode: boolean /** * The node's `hidden` input declarations, verbatim. * * Deliberately not merged into {@link inputs}: a hidden input is not a slot, * and listing it as one would put a connectable input on the node for * something the server fills in. * * Packs ship their own data here and read it back — easy-use and * tinyterraNodes both carry an XY-plot axis catalogue as * `input.hidden.plot_dict[0]`, on their own key, from their own Python spec. * That is the same passthrough reasoning `inputs[].options` already rests on, * and dropping it broke both packs against their own data. * * These are declarations, not values. `PROMPT`, `UNIQUE_ID` and * `EXTRA_PNGINFO` appear here as the type markers the node asked for; the * server substitutes the real thing at execution time and it never passes * through here. */ readonly hidden: Readonly> /** Which pack supplied it, when the backend reports one. */ readonly source: string | undefined } /** * Node output as it arrives from the backend. * * `raw` carries everything else verbatim — ADR 0007's passthrough schema * guarantees custom output keys survive, so a pack reading a bespoke key keeps * working. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface ExecutionResult { readonly images: readonly Readonly>[] readonly text: readonly string[] readonly raw: Readonly> } /** * A preview frame the backend produced while this node was running. * * Per node rather than per channel, deliberately. Packs currently subscribe to * `b_preview_with_metadata` *and* `b_preview`, track the executing node id in a * module global to correlate the second one, and probe * `serverSupportsFeature('supports_preview_metadata')` to decide which to * trust — all to answer "is this frame mine?". Answering it once here removes * the global, and with it the mis-attribution when two nodes preview at once. */ export interface PreviewFrame { readonly blob: Blob /** Object URL for the blob, revoked when the next frame arrives. */ readonly url: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface ConnectionChangeEvent { readonly side: 'input' | 'output' readonly index: number readonly connected: boolean /** * The node at the other end, or `undefined` on a disconnect. * * Packs read `link_info.origin_id` to decide what the new neighbour means — * retype a slot to match it, adopt its label. Knowing only that *something* * connected forced a re-walk of the whole graph to find out what. */ readonly peerNodeId?: string /** The slot index at the other end, or `undefined` on a disconnect. */ readonly peerIndex?: number } export interface NodeDefBuilder { /** Current state of the definition, after any earlier extensions ran. */ readonly def: NodeDef setTitle(title: string): void setCategory(category: string): void /** * Declares that this node type never reaches the backend. * * `defs.define` takes `execution: 'frontend'` for a type the pack owns, but * packs also mark *backend-registered* types frontend-only — a tools or * control node that exists to drive other nodes and must not appear in the * prompt. Without this they reach for `node.isVirtualNode`, and dropping that * line puts a new node into `graphToPrompt`, which is a wire-format break. * * Supply `resolve` when the node carries a value through to something else; * omit it and the node is simply left out. See `resolution.ts` — `resolve` is * pure over a read-only view and must not mutate the graph. */ setExecution(execution: 'backend' | 'frontend', resolve?: Resolver): void /** * Declares what this node feeds into *other* nodes' unconnected inputs. * * The counterpart of `setExecution`'s `resolve`, which answers only "what * feeds my own outputs" and is never called for a node with none. Broadcast * packs are the reverse: they name inputs on nodes that are not themselves, * and discover those edges rather than declaring them. * * Available here and not only on `defs.define` because the types that * broadcast are registered by the pack's Python, and `defs.define` refuses a * type that already exists — which left `supply` unreachable for every pack * that actually needed it. * * Not gated on `setExecution('frontend')`: feeding somebody else and being * skipped by the prompt builder are separate questions, and a node may * legitimately both execute and broadcast. */ setSupply(supply: Supplier): void addWidget(def: WidgetDef): void hideWidget(name: string): void // Behaviour hooks, ordered by measured usage across the 1,265 packs. /** * Fires once the node exists *and is addressable* — after it joins a graph. * * Deliberately not litegraph's `onNodeCreated`, which runs inside * `createNode()` before the node has an id, a graph, or store registration. * A handle is id-backed, so at that moment there is nothing to hand back, and * widget writes would land on an unregistered node and be lost on insert. */ onCreated(callback: (node: NodeHandle, event: NodeCreatedEvent) => void): void // 943 packs onExecuted( callback: (node: NodeHandle, result: ExecutionResult) => void ): void // 497 packs onConfigured( callback: (node: NodeHandle, data: Record) => void ): void // 429 packs onConnectionsChanged( callback: (node: NodeHandle, event: ConnectionChangeEvent) => void ): void // 223 packs onRemoved(callback: (node: NodeHandle) => void): void // 158 packs /** * The node was resized, by the user or by a layout pass. * * Packs hung a `ResizeObserver` on their mounted element to notice this, * which fires for the element rather than the node and misses a resize that * does not change the element. */ onResized(callback: (node: NodeHandle, size: Size) => void): void /** * The pointer entered or left the node. * * Packs read `canvas.node_over` or set `node.mouseOver` to rebuild a list * the moment the pointer arrives, or to decide which node a tooltip belongs * to. Both are canvas internals, and the canvas is what Nodes 2.0 replaces. */ onHover(callback: (node: NodeHandle, hovering: boolean) => void): void /** * The node was double-clicked. * * Deliberately carries no coordinates. Hit-testing a pointer against * node-local geometry is a pack drawing its own front end; the published * answer is `widgets.mount` and ordinary DOM events on the element you own. */ onDoubleClick(callback: (node: NodeHandle) => void): void /** * Whether this node can accept the current browser drag. * * The event is the browser's data-transfer surface, not a renderer object. * Returning `true` makes both node renderers present and route the drop. */ onDragOver( callback: (node: NodeHandle, event: DragEvent) => boolean | void ): void /** Handles a drop the node accepted. Returning `true` claims it. */ onDrop( callback: ( node: NodeHandle, event: DragEvent ) => boolean | void | Promise ): void /** * A property the user edited in the node's properties panel. * * Packs used `onPropertyChanged` to keep a hand-entered value sane — rgthree * clamps a seed's `randomMax` as it is typed. litegraph's own callback can * only veto, reverting to the previous value, which throws the user's input * away rather than correcting it. `setValue` replaces it instead, and writes * without going back through `setProperty`, so a clamp cannot recurse. */ onPropertyChanged( callback: (node: NodeHandle, event: PropertyChangeEvent) => void ): void /** Preview frames for this node, already correlated. */ onPreview(callback: (node: NodeHandle, frame: PreviewFrame) => void): void /** * Contributes the pack's own state to the saved node. * * The returned object is merged into the serialized node, and comes back * through `onConfigured`. Only keys the pack owns: core fields are not * writable from here, because a pack must not be able to change what the * workflow means. */ onSerialize(callback: (node: NodeHandle) => Record): void /** * Vetoes or permits an incoming connection *before* it is wired. * * Distinct from `onConnectionsChanged`, which fires after the fact — packs * use the pre-hook to refuse an incompatible link or relabel a slot while * the type is still known. Returning `false` refuses. */ onBeforeConnect( callback: (node: NodeHandle, event: BeforeConnectEvent) => boolean | void ): void /** * The user dropped a link on a node's body and the host found no single slot * that fits. Wire it yourself and return `true`; return nothing to let the * host report the drop unplaceable. * * For a node whose one slot carries a bundle of values — a context, a pipe — * and which wants to unpack it into several of the peer's slots at once. Both * ends of the drag are asked, the one the user aimed at first, because the * node with the knowledge is the drop target in one direction and the drag's * origin in the other. * * The published alternative to replacing `connectByType` on the prototype, * which is how packs did this: that changes link routing for every node in * the document, so one pack's convenience became every other pack's * behaviour. */ onUnplacedLink( callback: (node: NodeHandle, event: UnplacedLinkEvent) => boolean | void ): void /** Adds an entry to this node type's context menu. */ addMenuItem(item: NodeMenuItem): void } export interface NodeCreatedEvent { /** * The node arrived carrying saved state — pasted, duplicated, or loaded from * a workflow — rather than being made fresh. * * Read as "was `configure` called on it before it joined the graph", which is * what actually distinguishes the cases. Packs overrode `clone()` to reset * state a copy should not inherit — a duplicated node keeping the dynamic * slots that were fed by the original's upstream, a duplicated reroute born * hard-typed and refusing every other type — and `clone()` runs before the * node has an id, so there is nothing to hand a pack there. */ readonly restored: boolean /** * The whole graph was being loaded, so {@link restored} means "came from the * saved file" rather than "came from the clipboard". * * The distinction is the point: a pasted node should drop slots it cannot * still be fed through, and a loaded one must keep every one of them or the * workflow opens wrong. */ readonly loading: boolean } export interface UnplacedLinkEvent { /** Which of this node's slots the link would land on. */ readonly side: 'input' | 'output' /** The node at the other end of the drag. */ readonly peerNodeId: string /** The slot on the peer the drag started from. */ readonly peerIndex: number readonly type: string /** * The user held the modifier that means "overwrite what is already wired". * * Published because packs read a global keyboard service of their own to get * it, and which modifier means this is the host's to decide. */ readonly replaceExisting: boolean } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface BeforeConnectEvent { readonly side: 'input' | 'output' readonly index: number /** The node at the other end, when one is known. */ readonly peerNodeId: string | undefined /** The slot at the other end, when one is known. */ readonly peerIndex: number | undefined readonly peerType: string | undefined } /** One entry inside a menu item's submenu. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeSubMenuItem { readonly label: string run(node: NodeHandle): void } /** * One entry of ComfyUI's node palette: the title bar, the body, and the shade * a group of that colour is filled with. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeColor { readonly color: string readonly bgColor: string readonly groupColor: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeMenuItem { /** * A function when the text depends on the node — packs label entries with * the current state ("Unmute 3 nodes"), which a string fixed at * registration cannot express. */ readonly label: string | ((node: NodeHandle) => string) /** * Shown only when this returns true. Without it a pack that wants an entry * to appear conditionally has to either show it always or not at all — * efficiency-nodes hides its seed submenu when the feature is off, and * flattening that to a permanent entry is a worse lie than omitting it. */ when?(node: NodeHandle): boolean /** Omit when the item only opens a submenu. */ run?(node: NodeHandle): void /** * Turns the entry into a submenu. One level deep, deliberately: every * measured pack uses exactly one, and nesting further is a menu design * problem rather than an API one. * * A function when the children depend on the node's current state, which is * the common case rather than the exotic one: efficiency-nodes' LoRA Stacker * declares fifty `lora_name_N` widgets and lists only the two or three a * user has filled. A fixed array would put fifty rows in that menu, which is * a different menu, so the alternative to this was omitting the feature. */ readonly items?: | readonly NodeSubMenuItem[] | ((node: NodeHandle) => readonly NodeSubMenuItem[]) /** * Sort position among this node's pack-added entries. Lower first; entries * without one keep registration order, which is module-load order and so * depends on import sequence rather than intent. */ readonly order?: number } /** * Which definitions an extension applies to. * * Indexed rather than run-and-return: this predicate is almost always the guard * clause the pack already had at the top of its hook. */ export type DefSelector = | string | readonly string[] | RegExp /** * A predicate over the definition, for a guard the other forms cannot * express — "any node taking a VAE input", which is a shape rather than a * name. * * Deliberately last, and deliberately discouraged. The declarative forms * exist because a name check can be indexed, while a predicate has to run for * every registered type; with thousands of types that is the boot cost this * API set out to remove. Use it only when the guard genuinely reads a def's * inputs or outputs. */ | ((def: NodeDef) => boolean) /** * A `RegExp` category covers the prefix filter 53 packs open their hook with * (`nodeData.category.startsWith('KJNodes')` → `{ category: /^KJNodes/ }`). */ | { readonly category: string | RegExp } /** * A node type the pack owns, declared rather than subclassed. * * 86 packs (18.2% of installs) do this today with `extends LGraphNode` + * `LiteGraph.registerNodeType`, which is OOP entity modelling — the thing ADR * 0008 rules out. Here the definition is plain data; the class behind it is an * internal detail of this layer, never the pack's. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeDefinition { readonly type: string readonly title?: string readonly category?: string readonly description?: string readonly inputs?: readonly { name: string; type: string }[] readonly outputs?: readonly { name: string type: string shape?: SlotShape }[] readonly widgets?: readonly WidgetDef[] /** * `'frontend'` nodes never reach the backend: they are resolved away at * prompt time by the resolution system, or simply omitted. */ readonly execution?: 'backend' | 'frontend' /** * Answers what each output resolves to, purely, over a read-only view. * See `resolution.ts` — this replaces `applyToGraph`, which mutated the * live graph mid-serialize. */ readonly resolve?: Resolver /** * What this node feeds into *other* nodes' unconnected inputs. * * The broadcast direction: `resolve` cannot express it, because the nodes * being fed are not this one and the edges are discovered rather than * declared. */ readonly supply?: Supplier onCreated?(node: NodeHandle, event: NodeCreatedEvent): void onExecuted?(node: NodeHandle, result: ExecutionResult): void onConfigured?(node: NodeHandle, data: Record): void onConnectionsChanged?(node: NodeHandle, event: ConnectionChangeEvent): void onPropertyChanged?(node: NodeHandle, event: PropertyChangeEvent): void onDragOver?(node: NodeHandle, event: DragEvent): boolean | void onDrop?( node: NodeHandle, event: DragEvent ): boolean | void | Promise onRemoved?(node: NodeHandle): void onSerialize?(node: NodeHandle): Record } export interface DefRegistry { /** * Declares how an input *type* is presented — the replacement for * `getCustomWidgets`. * * Not decoration: the host decides widget-vs-socket purely by whether a type * is registered, so an unregistered one turns the input into a socket and * drops its value from `widgets_values`. See `widgetTypes.ts`. */ defineWidgetType(type: string, def: WidgetTypeDef): Unsubscribe /** * Registers a node type the pack owns. Returns a handle that unregisters * it — which `LiteGraph.registerNodeType` never offered. */ define(definition: NodeDefinition): Unsubscribe get(type: string): NodeDef | undefined all(): readonly NodeDef[] has(type: string): boolean extend( selector: DefSelector, apply: (builder: NodeDefBuilder) => void ): Unsubscribe /** * Asks the host to reload node definitions from the backend. * * Combo inputs whose values the backend supplies — model lists, LoRA names, * sampler names — are captured when definitions load, so a pack that adds a * file server-side leaves every open picker showing the old list. This is * `app.refreshComboInNodes()`, which packs called after saving a model * preview or writing a new file. * * Refreshing is not free: it refetches every definition. Call it after a * change the user made, not on a timer. */ /** * The colour links and slots of a type are drawn in. * * A pack matching the theme in its own DOM — a legend, a chip, a preview — * read `LGraphCanvas.link_type_colors` for this. Reading a design token to * match is the opposite of drawing your own front end, so it is published; * the table itself is not. */ typeColor(type: string): string /** * The colours behind a name in ComfyUI's node palette — `red`, `pale_blue` — * or `undefined` for a name it does not define. * * Same reasoning as {@link typeColor}, and the same limit: the resolver is * published, the table is not. What makes this a design token rather than a * renderer internal is that the names are the user's own vocabulary. They * pick "green" from a menu; nothing records the word, only the hex it stood * for. So a pack offering "mute every red group" cannot match what the user * chose without being told which hex "red" meant, and two packs did it by * reading `LGraphCanvas.node_colors` directly. * * Colours move with the palette, names do not. Resolve on use; do not cache * the result and do not persist it in a workflow. */ nodeColor(name: string): NodeColor | undefined /** * Tests an output type against an input type using the host's connection * rules, including wildcards and comma-delimited unions. */ isTypeCompatible(outputType: string, inputType: string): boolean /** * Declares the colour for a data type this pack introduces. * * Packs shipping their own types — `PIPE_LINE`, `LORA_STACK`, `XYPLOT` — * wrote straight into `LGraphCanvas.link_type_colors` so their links were * not all grey. * * Refuses a type the host already colours. That write is global: one pack * recolouring `IMAGE` restyles every graph for every other pack and the * user has no way to see who did it. Colouring a type you brought is * additive; colouring one you did not is not yours to decide. */ setTypeColor(type: string, color: string): Unsubscribe refresh(): Promise /** * Node definitions were reloaded — by this pack, another pack, or the user. * * The listening half of `refresh()`, and what the `refreshComboInNodes` * extension hook gave packs. A pack holding its own cached copy of a combo's * values — a model list it filters, a picker it built — needs to rebuild it * when the list changes underneath, and the pack that caused the change is * usually not this one. */ onRefreshed(listener: () => void): Unsubscribe } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface PropertyChangeEvent { readonly name: string readonly value: unknown readonly previous: unknown /** Replaces what is stored. Last writer wins if several packs respond. */ setValue(value: unknown): void /** Discards the edit, restoring `previous`. */ reject(): void } // ─── documentHandle.ts ─────────────────────────────────────────── export interface DocumentHandle extends HandleCommon { /** * Identity of this editing session. Stable for as long as the document is * open — including across undo, redo and tab switches — and never reused. * * Not the id inside the workflow JSON, which travels with the file, so two * opens of it and any copy made outside the app all share one value. Not the * path either, which is a storage address and changes on rename. Do not * persist this: it means nothing in the next page load. */ readonly id: string /** Display name, without the directory or extension. */ readonly name: string | undefined /** * Storage path, for addressing the file. Undefined for a document with no * file behind it yet. Changes when the user renames, so key pack state on * {@link id} instead. */ readonly path: string | undefined /** Whether there are edits the user has not saved. */ readonly isModified: boolean /** * True once this editing session has ended. * * A handle is a snapshot of a session, and a pack may hold one across a tab * close or a background unload. Check before acting on stored state rather * than trusting a captured handle, exactly as for a node or a widget. */ readonly isDeleted: boolean } /** What the host must supply to describe one open document. */ export interface DocumentSource { readonly sessionId: string | null readonly filename?: string readonly path?: string readonly isModified?: boolean /** Whether this is the document the editor is showing. */ readonly isActive?: boolean } /** * Every document currently open, including background tabs. * * One reader rather than one per question: a handle has to answer for a * document that is open but not on screen, and a lookup that only knew the * active one would report every background tab as closed. */ export type DocumentReader = () => readonly DocumentSource[] // ─── documentLifecycle.ts ──────────────────────────────────────── /** * The transitions a document makes. * * `opened` and `closed` bracket the session's existence; `activated` and * `deactivated` bracket its time on screen. A document opened in the * background is `opened` without being `activated`, which is why they are * separate: a pack that allocates on `opened` and releases on `closed` stays * balanced no matter how the user moves between tabs. */ export type DocumentPhase = 'opened' | 'activated' | 'deactivated' | 'closed' // ─── graphHandle.ts ────────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeInit { title?: string position?: { x: number; y: number } } /** * How far {@link GraphHandle.queryNodes} looks. * * `'visible'` is the graph on screen and the default, matching `nodes()`. * `'root-and-subgraphs'` is the root graph and every subgraph *definition* — * the same set `onNodeChanged`'s `'document'` scope reports over. A subgraph * placed three times contributes its nodes once, which is what a pack acting * on "each of my nodes" means. * * @knipIgnoreUnusedButUsedByCustomNodes */ export type NodeQueryScope = 'visible' | 'root-and-subgraphs' /** * Which nodes {@link GraphHandle.queryNodes} should return. * * Every field narrows; omitting all of them returns the whole scope. They * compose as AND, because the cases packs actually hand-rolled — "my nodes, * anywhere in the document", "everything in this group" — are intersections. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface NodeQuery { readonly scope?: NodeQueryScope /** * Node type. A string matches exactly, an array matches any of them, and a * regular expression matches by pattern — which is how a pack asks for its * own nodes without listing every type it ships. */ readonly type?: string | RegExp | readonly string[] /** * Restrict to nodes in the graph the user is looking at. * * Only meaningful under `'root-and-subgraphs'`: it is the difference between * "every node in the document" and "the ones the user can currently see". * This is *not* a viewport test — a node scrolled off the edge of a graph * the user is in is still rendered by this definition. Culling belongs to * the renderer and differs between the two of them. */ readonly rendered?: boolean /** Restrict to nodes the group currently contains. */ readonly group?: GroupHandle } export interface GraphHandle { readonly id: string node(id: string): NodeHandle | undefined nodes(): readonly NodeHandle[] nodesOfType(type: string): readonly NodeHandle[] /** * One flat query over graph-scoped nodes. * * `nodes()` and `nodesOfType()` address the graph on screen, so a pack that * wanted "every node of mine in this document" had to walk `root()` and each * `subgraphs()` entry itself and concatenate the results — and the ones that * did not simply stopped working the moment a user nested anything. * * Handles come from the scope that owns each node, so a node reached here * under `'root-and-subgraphs'` is not `===` the one `graph.node()` returns * for it. That is the same scope rule `sameEntity()` exists for; compare * with `comfy.sameEntity()` rather than `===`. */ queryNodes(query?: NodeQuery): readonly NodeHandle[] add(type: string, init?: NodeInit): NodeHandle remove(id: string): boolean links(): readonly LinkInfo[] /** * The supply edges prompt execution would use in this graph right now. * * Re-runs the registered pure suppliers and the host's priority arbitration, * returning graph-local ids suitable for {@link OutputSlotHandle.connectTo}. * Exact priority ties are absent, just as they are from the prompt. The * frozen snapshot never mutates the graph. */ resolvedSupplies(): readonly ResolvedSupply[] /** * The nodes the user currently has selected. * * 15 packs read `canvas.selected_nodes` or `selectedItems` for this — a * canvas internal, and the canvas is exactly what Nodes 2.0 replaces. * Selection is a property of the document, so it is asked of the graph. */ selection(): readonly NodeHandle[] /** * Replaces the selection with these nodes. An empty list clears it. * * A node a pack just created is the usual case — `LGraphCanvas.add`'s * `options.select` put it straight under the user's cursor, and without this * the node appears but the user has to find and click it. * * `add: true` extends the selection instead of replacing it. */ select(nodes: readonly NodeHandle[], options?: { add?: boolean }): void /** * Pans the view so a node sits in the middle of it. * * Packs wrote `canvas.ds.offset` themselves to do this, which bakes in the * renderer's transform and the device pixel ratio. Does not change zoom. */ centerOn(node: NodeHandle): void /** * The groups on the canvas, in draw order. * * Packs read `graph._groups` to build a group muter, a group runner, or a * navigator. A group is a rectangle plus a title: which nodes it holds is * derived from what it overlaps, which is why `nodes()` is a method and not * a stored list. */ groups(): readonly GroupHandle[] /** * Scales the view. 1 is unzoomed. * * Packs saved a zoom level alongside a node to restore a view; without this * a bookmark could pan but the number it stored was inert. Clamped to what * the canvas allows, so a stored extreme cannot strand the user. */ setZoom(scale: number): void /** * Where the pointer is, in graph space — the coordinates {@link nodeAt} and * {@link NodeHandle.setPosition} use. * * A pack adding a node from a menu put it under the cursor. Without this the * node lands at the graph origin, which on any panned view is off screen. * * `undefined` when there is no canvas to measure against. */ pointerPosition(): Point | undefined /** * The document's root graph, even while the user is viewing a subgraph. * Undefined before a document exists. */ root(): GraphScopeHandle | undefined /** * The subgraph definitions in the document, each scoped to its own nodes. * * `nodes()` and `node()` address the graph on screen only, so a pack that * must reach every node — refreshing its own nodes after a run, walking a * chain — misses anything nested. * * Access is *through* the subgraph rather than a flattened list. Ids are * allocated from the root graph's counter, so they do not collide among * nodes created in one session — but a subgraph loaded from a file brings * its authored ids, and `configure` raises that counter without renumbering * anything. Two independently authored subgraphs can therefore carry the * same id. Resolving inside the owning graph is correct either way, and does * not rest on an invariant litegraph does not promise. * * These are definitions, not instances. A subgraph placed three times has * one entry, and its nodes appear once — which is what a pack acting on * "each of my nodes" wants. */ subgraphs(): readonly GraphScopeHandle[] /** * Runs several mutations as one undo step. * * Without it, a pack that adds three nodes and wires them leaves the user * pressing undo four times to get back. `graph.beforeChange()` / * `afterChange()` did this by counting nesting depth. * * A scope rather than a pair of calls: the counter only captures when it * returns to zero, so one throw between a manual `before` and `after` stops * undo capturing anything at all, for the rest of the session, with nothing * to show why. The scope closes on the way out either way. * * Synchronous on purpose. Holding the group open across an `await` would * fold whatever the user did while waiting into the pack's undo step. */ batch(mutations: () => T): T /** * The topmost node at a point in graph space, if any. * * Packs building a gesture were walking every node and re-deriving its * rectangle from renderer constants. The graph already knows, and its answer * respects z-order, collapsed nodes and the active renderer's layout. * * Answers against the *rendered* layout, which is the only sensible reading * of "what is under this point" — and is why it is not refreshed per call: a * gesture asks this on every pointer move, and remeasuring every node each * time would be the expensive mistake. Before the first frame it finds * nothing. */ nodeAt(point: { x: number; y: number }): NodeHandle | undefined /** * A copy of a node, carrying its widget values and properties, added to the * graph without links. * * `add(type)` only makes a fresh node of a type, so a pack duplicating a * configured node — a prompt box the user has filled in — had no way to keep * what it contained. Links are deliberately not copied: a duplicate wired * into the same places is a different operation, and the caller can connect * it themselves. * * `undefined` if the node is gone, or if its type is not registered — the * copy is built through the registry, so there is nothing to build from. * Widget values carry over only for a type that serializes them, which every * backend-registered type does. */ duplicate( id: string, position?: { x: number; y: number } ): NodeHandle | undefined /** * Rebuilds a node, optionally as another type, keeping what the user set and * every link that still fits. Replacing with the same type repairs a node * whose registered definition changed without discarding its state. * `undefined` if the node is gone; throws if the type is not registered. * * This is a real feature four packs ship — "Convert to Context Big", "Swap to * KSampler (Efficient)" — and all four hand-rolled it out of `graph.links`, * `getNodeById` and `LiteGraph.createNode`, which is most of what this * migration exists to delete. All four also got it wrong: one drops every * widget value and hardcodes "slot 0 only", the other recurses through * requestAnimationFrame forever on an inverted comparison and leaves a * separate undo step for the add, each connection, and the remove. * * Position, custom title, colour, mode, declared properties and widget values * carry over by name. Size is the larger of what the user set and what the new * type needs, so a node that grew more slots is not clipped. Links are re-made * by slot name, falling back to the same index; type checking is the ordinary * connection rule, so a link that no longer fits is dropped and warned about * rather than forced. The whole swap is one undo step. */ replace(id: string, type: string): NodeHandle | undefined /** * Changes when the graph does: nodes added, removed or reconfigured, links * connected or disconnected, slots and subgraph inputs/outputs altered, and * the node flags a reader can see — collapsed, pinned, advanced. * * Hold one and compare it later to learn whether anything moved since. That * is the whole contract: an opaque token, not a count. Do not subtract two * of them, do not expect it to start anywhere in particular, and do not * expect consecutive changes to differ by one. Coalesced edits are free to * advance it once, and `batch()` exists precisely so they can. * * A widget value committed by the user or through * `WidgetHandle.setValue()` advances it through the same host protocol. Data * a pack keeps outside graph and widget state does not; a canvas widget * holding such data has `redraw()`. */ readonly version: number /** Diagnostics: live handle-cache slots across all kinds. */ readonly cacheSize: number } /** * A subgraph definition, scoped to its own contents. * * Deliberately narrower than {@link GraphHandle}: adding, selecting, centring * and zooming all address what the user is looking at, and a subgraph * definition is not that. This is for reading and reaching nodes. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface GraphScopeHandle { /** Stable across every instance of this subgraph. */ readonly id: string readonly name: string | undefined nodes(): readonly NodeHandle[] node(nodeId: string): NodeHandle | undefined /** * The groups drawn inside this subgraph. * * A group muter or runner that skipped these reported nothing for a * subgraph's contents while appearing to work. */ groups(): readonly GroupHandle[] /** The supply edges prompt execution would use inside this graph. */ resolvedSupplies(): readonly ResolvedSupply[] } // ─── groupHandle.ts ────────────────────────────────────────────── export interface GroupHandle { readonly id: string getTitle(): string setTitle(title: string): void /** Colour as the renderer holds it, or undefined for the default. */ getColor(): string | undefined setColor(color: string): void /** * The nodes the group currently contains, recomputed on each call. * * Packs muted or queued "the group", which always meant its nodes. Do not * cache the result: a drag changes it with no event. */ nodes(): readonly NodeHandle[] /** The group's rectangle in graph space, title bar included. */ getBounds(): Bounds /** Pans the view so this group is in the middle of it. Zoom is unchanged. */ centerOn(): void } // ─── interaction.ts ────────────────────────────────────────────── export interface NodeMoveEvent { readonly node: NodeHandle readonly position: { readonly x: number; readonly y: number } } /** * Where movement comes from, supplied by the renderer. * * `platform/` cannot import `renderer/`, and the layout store lives there. This * is the same seam `registerBadgeRowsProvider` uses so litegraph never reaches * into the store: the upper layer pushes the source down at boot. */ export type NodeMoveSource = ( onMove: (nodeId: string, position: { x: number; y: number }) => void ) => Unsubscribe /** Reports a completed drag with the ids of every node it moved. */ export type NodeDragEndSource = ( onDragEnd: (nodeIds: readonly string[]) => void ) => Unsubscribe // ─── nodeChanges.ts ────────────────────────────────────────────── /** A field the host tracks and reports. Not every property is one. */ export type TrackedProperty = | 'title' | 'mode' | 'color' | 'bgcolor' | 'shape' | 'showAdvanced' /** * Which graphs a listener hears from. * * `'visible'` is the default and the graph on screen, following the user into * and out of subgraphs — what a pack decorating what the user is looking at * wants. * * `'document'` is the root graph and every subgraph definition. A pack that * *computes* from other nodes needs it: rgthree's relay derives a group's mute * state from its inputs, and inside a subgraph the user had navigated away from * it stopped recomputing while still asserting its last answer — so a group * stayed muted against its inputs, intermittently, and healed on navigation. */ export type NodeChangeScope = 'visible' | 'document' export interface NodeChangeOptions { scope?: NodeChangeScope } export interface NodeChangeEvent { /** The node that changed. It may belong to another pack, or to none. */ readonly node: NodeHandle /** * The graph the change happened in — the root graph's id, or a subgraph * definition's. Node ids are unique only within a graph, so a pack keeping * its own records under `'document'` must key on both. */ readonly graphId: string /** * The editing session the change happened in, or `undefined` when the host * cannot name one. * * `graphId` is restored from the saved workflow and round-trips through * `serialize()`, so it identifies the graph on disk, not the document open * in front of the user — two opens of one file report the same value. A pack * holding records across a document swap needs this to know they are stale. */ readonly documentId: string | undefined readonly property: TrackedProperty readonly from: unknown readonly to: unknown } // ─── nodeHandle.ts ─────────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export type NodeMode = 'always' | 'never' | 'bypass' | 'on-event' | 'on-trigger' /** @knipIgnoreUnusedButUsedByCustomNodes */ export type NodeShape = 'default' | 'box' | 'round' | 'circle' | 'card' export interface BadgeDef { readonly text: string /** Text colour. Defaults to core's badge foreground. */ readonly color?: string /** Background colour. Defaults to core's badge background. */ readonly bgColor?: string /** * Makes the badge clickable. * * Two conversions declined to turn a button into a badge because a badge * that looks pressable and does nothing is worse than the thing it replaced. */ onClick?(): void } export interface Point { readonly x: number readonly y: number } export interface Size { readonly width: number readonly height: number } /** A rectangle in graph space. */ export interface Bounds { readonly x: number readonly y: number readonly width: number readonly height: number } export interface NodeSnapshot { readonly id: string readonly type: string readonly title: string readonly mode: NodeMode readonly collapsed: boolean readonly pinned: boolean readonly color: string | undefined readonly bgColor: string | undefined readonly shape: NodeShape readonly position: Point readonly size: Size } /** * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract: * accessor methods rather than properties, so a read can be a store query and * a write can dispatch a command. */ export interface SizeConstraints { minWidth?: number minHeight?: number maxWidth?: number maxHeight?: number /** Grow to fit content rather than holding a fixed height. */ autoHeight?: boolean } export interface NodeHandle extends HandleCommon { readonly id: string readonly type: string readonly comfyClass: string getTitle(): string setTitle(title: string): void getMode(): NodeMode setMode(mode: NodeMode): void isCollapsed(): boolean setCollapsed(collapsed: boolean): void isPinned(): boolean setPinned(pinned: boolean): void getColor(): string | undefined setColor(color: string | undefined): void getBgColor(): string | undefined setBgColor(color: string | undefined): void getShape(): NodeShape setShape(shape: NodeShape): void getProperty(key: string): T | undefined getProperties(): Readonly> setProperty(key: string, value: WidgetValue): void /** * Whether this node emits `widgets_values` when the workflow is serialized. * * Writable because packs vary it per node type, and the value is part of the * wire format — a conversion that could not set it would change what the * saved workflow contains. */ isSerializingWidgets(): boolean setSerializeWidgets(serialize: boolean): void getPosition(): Point setPosition(pos: Point): void getSize(): Size /** Changes size through the host's resize protocol, including `onResized`. */ setSize(size: Size): void /** * The node's rectangle in graph space, title bar included. * * `getPosition()` is the body's top-left, so packs building a gesture were * reconstructing this by subtracting a title height read off the renderer — * which is only right for the default layout, and wrong for a collapsed node * or under a different renderer. Ask the renderer instead of re-deriving it. */ getBounds(): Bounds /** * Where a slot sits, in graph space. * * The renderer's own answer, so it stays correct for collapsed nodes, * widget-backed inputs and layouts that are not the default vertical stack — * all cases the `(index + 0.7) * slotHeight` reconstruction gets wrong. * * `undefined` if there is no slot at that index. */ getSlotPosition(side: 'input' | 'output', index: number): Point | undefined /** * Where the node currently sits on screen, in client coordinates. * * For anchoring a floating panel to a node. Packs did this by reading the * viewport's pan and zoom and doing the arithmetic themselves, which is both * the renderer's business and wrong the moment the transform changes shape. * * The answer already accounts for zoom, so a pack needing to convert a pixel * drag into graph units can divide by `width / getBounds().width` rather than * asking for the scale factor. * * `undefined` when nothing is on screen to measure against. */ getScreenRect(): Bounds | undefined /** * URLs of the images this node produced when it last executed. * * Packs read `node.imgs` — the loaded `HTMLImageElement`s core hangs on the * node — to walk upstream for the nearest ancestor holding a composite, or * to scan the selection for something to feed an editor. `onExecuted` does * not answer that: it is per node type, so it never sees another pack's * outputs, and it only fires at the moment of execution. * * URLs rather than elements, deliberately. The loaded element is the * renderer's, and its lifetime is the renderer's; a pack that wants pixels * can load the URL itself and own the result. This also covers previews, * which are what the node is showing when a run is still in flight. * * Empty when the node has not produced images. */ getOutputImages(): readonly string[] /** * Which of {@link getOutputImages} the user is looking at, or `undefined` * when they have neither selected nor hovered one. * * A pack copying "the image" or saving one as a model's preview meant the * one under the cursor, not the first of the batch. `undefined` is why this * is not simply `0`: an entry that acts on a guess writes the wrong file to * the server, silently. */ getDisplayedImageIndex(): number | undefined /** * The id of the graph holding this node — the root graph's id, or a * subgraph's. * * A pack keeping its own records against nodes needs it: node ids are unique * per graph, so a key built from the id alone collides once subgraphs are * involved. Pair it with `comfy.graph.subgraphs()` to get back to the node. */ readonly graphId: string | undefined /** * Puts a small label on the node's title bar. Returns a handle that removes * it again. * * Packs draw a status, a count, a cost, a model name. They did it by * overriding `onDrawForeground` and painting into the canvas context, which * only works under the legacy renderer and puts the pack in the business of * laying out text. `badges` is core's own extension point and both renderers * draw it. * * Pass a function for a label that changes: it is called each time the node * is drawn, so return quickly and do not build strings you could cache. */ addBadge(badge: BadgeDef | (() => BadgeDef)): Unsubscribe /** * Declares how the node may be sized, instead of re-asserting it per frame. * * 39 packs recompute size inside a draw or resize callback, which is both a * per-frame cost and a fight with the layout. `autoHeight` is usually the * real intent: the pack mounted something of unknown height and wants the * node to fit it. */ setSizeConstraints(constraints: SizeConstraints): void getSizeConstraints(): Readonly readonly inputs: SlotCollection readonly outputs: SlotCollection readonly widgets: WidgetCollection snapshot(): Readonly | undefined remove(): void } /** Per-node collections, supplied by the graph layer that owns their caches. */ export interface NodeCollections { inputs(nodeId: string): SlotCollection outputs(nodeId: string): SlotCollection widgets(nodeId: string): WidgetCollection } // ─── queueHandle.ts ────────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunOptions { /** * Run only these nodes and whatever feeds them, instead of the whole * workflow. Empty is rejected rather than treated as "everything": a filter * that matched nothing must not silently run the entire graph. */ nodes?: readonly NodeHandle[] /** How many times to run. Defaults to 1. */ batch?: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunSubmittedEvent { /** Ids the backend accepted, in submission order. */ readonly promptIds: readonly string[] /** The accepted prompts and how many backend nodes each will execute. */ readonly submissions?: readonly RunSubmission[] /** How many submissions the backend refused. */ readonly rejected: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunSubmission { readonly promptId: string readonly nodeCount: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunRejectionError { readonly type: string readonly message: string readonly details: string readonly inputName?: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunRejectedNode { readonly nodeId: string readonly nodeType: string readonly errors: readonly RunRejectionError[] } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface RunRejectedEvent { readonly status?: number readonly error: RunRejectionError readonly nodeErrors: readonly RunRejectedNode[] } /** @knipIgnoreUnusedButUsedByCustomNodes */ export type AutoQueueMode = 'disabled' | 'change' | 'instant' export interface QueueHandle { /** * Queues the current workflow, exactly as pressing Run does. * * Resolves once the prompt has been submitted — not when it finishes * executing. `false` means another queue call was already in flight and this * one was folded into it. */ run(options?: RunOptions): Promise /** * A run is about to be submitted. * * This is `beforeQueuing`. For a last write before the prompt is built — * syncing a value the pack keeps outside the widget. Keep it synchronous: * the prompt build does not wait, so work started here can lose the race. */ /** * Return a function to have it run when the attempt is over — whether the * run started, was refused, or threw. * * For a pack that changes the graph to build the prompt and must put it back: * unmute a branch, let the prompt be built, re-mute it. Pairing it with the * setup rather than publishing a second top-level event is deliberate — you * cannot receive the cleanup without having run the setup, and there is no * second "after" member to confuse with {@link onAfterRun}, which means * something different and narrower. */ onBeforeRun(listener: () => (() => void) | void): Unsubscribe /** * A run was submitted. This is `afterQueued` — for advancing state that * should differ on the next run. * * The event names what the backend accepted, so a pack can tie its own * progress tracking to the run it started rather than guessing that the next * execution message belongs to it. Each submission includes the exact count * of executable backend nodes without exposing the built prompt. `rejected` * is how many submissions the backend refused: `onBeforeRun` fires either * way, so without this a pack cannot tell a run that started from one that * never did. */ onAfterRun(listener: (event: RunSubmittedEvent) => void): Unsubscribe /** * The backend refused a submitted prompt before execution began. * * This exposes prompt and per-node validation details without coupling a * pack to host notifications. It does not fire for transport failures or an * error raised after execution starts. */ onRejected(listener: (event: RunRejectedEvent) => void): Unsubscribe /** * How many runs are waiting, including the one executing. * * Packs tracked this from the backend's own `status` message to re-implement * `app.ui.lastQueueSize` — deciding whether a button says Run or Cancel, * whether an auto-runner should submit again. */ pending(): number /** Fires whenever {@link pending} changes, with the new count. */ onPendingChanged(listener: (pending: number) => void): Unsubscribe /** * Cancels the run in progress. The rest of the queue is untouched. * * Packs wrapped `api.interrupt` both to call it and to notice one — a node * waiting on the user needs to stop waiting when the run is cancelled. * {@link onInterrupted} is that second half. */ interrupt(): Promise /** Execution was interrupted, by this pack, another, or the user. */ onInterrupted(listener: () => void): Unsubscribe /** The user-facing automatic queue mode. Both internal instant states read as `instant`. */ autoQueueMode(): AutoQueueMode /** Changes automatic queuing. `instant` arms continuous execution. */ setAutoQueueMode(mode: AutoQueueMode): void /** The batch count the host's own Run action will use. */ batchCount(): number /** Changes the host Run action's batch count. */ setBatchCount(count: number): void /** * Turns off automatic queuing without cancelling the current run. * * A conditional workflow can use this before interrupting itself so the * stopped iteration does not immediately start again. */ disableAutoQueue(): void /** * Holds a run until a check finishes, and can cancel it. * * {@link onBeforeRun} only observes: it is a notification, and the prompt * build does not wait. Packs that needed to *stop* a run — confirm an * incoming prompt, validate a field, warn about a cost — wrapped * `app.queuePrompt` to do it, which is the surface being retired. * * Return `false` to cancel. Every guard runs, and any one `false` cancels; * the user is not asked twice. * * A guard that never settles would make the application unrunnable, so one * that takes longer than a few seconds is abandoned and the run proceeds. Do * not put a dialog with no timeout behind this. */ guard(check: () => boolean | Promise): Unsubscribe } // ─── resolution.ts ─────────────────────────────────────────────── /** * "Whatever feeds this input." The only way one resolution names another. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface InputRef { readonly nodeId: string readonly input: number } export type OutputResolution = | { readonly omit: true } | { readonly forwardTo: InputRef } | { readonly literal: WidgetValue } /** * What a resolver may see. Reads only — there is nothing here that writes. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface ResolvedNodeView { readonly id: string readonly type: string /** * The node's own properties, frozen. * * A broadcaster keeps its per-node opt-in here — cg-use-everywhere reads * `properties.ue_properties` to decide what it may feed. Candidate inputs * already carry `nodeProperties`, so without this a supplier could read * every node's configuration except its own. */ readonly properties: Readonly> /** The groups this node sits inside — the other half of "my group". */ readonly groups: readonly GroupMembership[] /** Muted, bypassed or normal, as `LGraphEventMode`. */ readonly mode: number readonly color: string | undefined /** * This node's own inputs. * * `unconnectedInputs()` already describes every *other* node's slots, and a * supplier needs the same of its own: "send whatever is plugged into me to * every unconnected input of the same type" cannot be written without * knowing what type is plugged in. Without it a supplier is type-blind and * would feed a CLIP into a MODEL slot in silence. * * `type` is the slot's declared type; `connectedType` is what actually * arrives, resolved through reroutes, and is undefined when nothing is * connected. */ readonly inputs: readonly OwnInput[] /** This node's own outputs, in slot order. */ readonly outputs: readonly OwnOutput[] widgetValue(name: string): WidgetValue | undefined input(ref: string | number): InputRef | undefined } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface ResolveView { readonly self: ResolvedNodeView nodesOfType(type: string): readonly ResolvedNodeView[] } /** * May answer asynchronously: a sandboxed pack's resolver runs in a worker, so * its answer can only arrive as a promise. The prompt path awaits it; the * synchronous entry points (`input.resolvedSource()`, `resolvedSupplies()`) * treat a promise as unresolved and say so — see `resolution.async.test.ts`. */ export type Resolver = ( view: ResolveView ) => | Record | Promise> /** Where an output ends up after every frontend node in the chain resolves. */ export type ResolvedSource = | { readonly kind: 'output' readonly nodeId: string readonly output: number } | { readonly kind: 'literal'; readonly value: WidgetValue } | { readonly kind: 'omitted'; readonly reason: string } /** An input in the graph that no link feeds. */ /** One of a node's own inputs, as its supplier sees it. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface OwnInput { readonly index: number readonly name: string /** What the user sees — `label`, else `localized_name`, else `name`. */ readonly label: string readonly type: string readonly connected: boolean /** The type actually arriving, or undefined when nothing is connected. */ readonly connectedType: string | undefined /** The node feeding this input, if any. */ readonly sourceNodeId: string | undefined } /** One of a node's own outputs, as its supplier sees it. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface OwnOutput { readonly index: number readonly name: string /** What the user sees — `label`, else `localized_name`, else `name`. */ readonly label: string readonly type: string } /** A group a node sits inside. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface GroupMembership { readonly id: string readonly title: string } export interface UnconnectedInput { readonly nodeId: string readonly nodeType: string readonly input: number readonly name: string readonly type: string /** * What the user actually sees on the slot — `label`, else `localized_name`, * else `name`. Broadcast packs match against this, not `name`, and the two * differ in every non-English locale. */ readonly label: string /** The socket form of a widget rather than a plain input. */ readonly isWidgetInput: boolean /** The owning node, for matching by title, mode, colour, or opt-in flags. */ readonly nodeTitle: string readonly nodeMode: number readonly nodeColor: string | undefined /** * The groups the owning node sits inside, innermost first. * * Broadcast packs restrict by group — "only nodes in my group", "only nodes * outside it", "only groups whose title matches this regex". Membership is * geometric and recomputed here, so it matches what the user sees rather * than anything stored. */ readonly nodeGroups: readonly GroupMembership[] /** * The owning node's properties, frozen. * * Broadcast packs keep their per-node opt-in here — which inputs a user has * allowed to be fed. Without it a supplier can only match by type and would * feed every unconnected input of that type, which is the silent * wrong-broadcast failure this view exists to prevent. */ readonly nodeProperties: Readonly> } /** * An edge a node supplies into somebody else's unconnected input. * * `from` is the supplier's own output index, or a literal. It is deliberately * not an arbitrary node reference: a node may only offer what it itself has, * so one pack cannot rewire two other nodes to each other. */ export interface SuppliedEdge { readonly to: InputRef /** * Which claim wins when several suppliers name the same input. Higher wins; * defaults to 0. * * **Equal claims feed nothing.** Two suppliers that both say "highest * priority" for one input have no correct answer, and picking either makes * the prompt depend on node order — so the input is left unfed and the * conflict logged. That is what the broadcast pack this exists for does, and * it is the only choice that cannot silently produce a different image. */ readonly priority?: number readonly from: | { readonly output: number } | { readonly literal: WidgetValue } /** * Whatever feeds this node's own input `k` — for a node that rebroadcasts * its upstream rather than producing a value. * * The broadcast nodes this exists for have inputs and **no outputs**, so * `{ output: n }` cannot describe them: it would name a slot the backend * never declared and force it to execute a node that produces nothing. * Resolved exactly as `Resolver`'s `forwardTo`, so it chains through * reroutes for free. */ | { readonly forwardInput: number } } export interface SupplyView { readonly self: ResolvedNodeView nodesOfType(type: string): readonly ResolvedNodeView[] /** * Every unfed input in the graph — what a broadcaster matches against by * type, by name, or by its own regex. */ unconnectedInputs(): readonly UnconnectedInput[] } /** * Answers "what do I feed", the mirror of `Resolver`'s "what feeds me". * * `Resolver` is demand-side: it is asked about the resolver's own outputs, and * is never called for a node with none. cg-use-everywhere broadcasts a value * into every matching unconnected input in the graph, which that shape cannot * express at all — the nodes being fed are not the resolver, and the edges are * discovered rather than declared. Hence a second, supply-side pass. * */ /** May answer asynchronously, under the same rules as {@link Resolver}. */ export type Supplier = ( view: SupplyView ) => readonly SuppliedEdge[] | Promise /** * One winning supply after priority arbitration and source resolution. */ export interface ResolvedSupply { /** The node whose supplier offered this edge. */ readonly supplierNodeId: string /** The unconnected input the supplier won. */ readonly to: InputRef /** The final source the prompt builder will use. */ readonly from: ResolvedSource } // ─── settingsHandle.ts ─────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export type SettingValue = string | number | boolean | readonly string[] /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SettingDef { /** * Namespaced, by convention `.` — it shares one space with core * and every other pack, and it is what the value is stored under forever. */ readonly id: string readonly name: string /** * Which control the panel shows. Every one of these is declarative — the * host renders it. * * A pack-supplied renderer is deliberately absent. Core's own setting type * accepts a function that is handed the value and a setter and returns an * element; publishing that would put packs in charge of the settings * panel's markup, which is the thing that cannot then be restyled. Packs * that needed a colour or a file were falling back to a text field the user * pasted into, so the gap was the missing *types*, not a missing slot. */ readonly type: | 'boolean' | 'number' | 'slider' | 'knob' | 'combo' | 'radio' | 'text' | 'password' | 'color' | 'image' | 'url' readonly defaultValue: SettingValue readonly tooltip?: string /** Panel grouping. Defaults to the id split on dots. */ readonly category?: readonly string[] /** * Choices for `combo` and `radio`. * * A bare string is both the stored value and the label. Use the pair form * when they differ — several packs store a semantic number and show words * for it (`0` = off, `1` = selected, `2` = all), and comparing those * numerically is the whole point. Flattening them to strings silently * re-types every user's saved choice. */ readonly options?: readonly SettingOption[] /** * Bounds for `number` and `slider`. Without these a slider has no range to * draw and packs fall back to a plain text box. */ readonly attrs?: SettingAttrs readonly onChange?: (value: SettingValue, previous?: SettingValue) => void } /** @knipIgnoreUnusedButUsedByCustomNodes */ export type SettingOption = | string | { readonly value: string | number; readonly label: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SettingAttrs { readonly min?: number readonly max?: number readonly step?: number } export interface SettingsHandle { /** * Registers a setting. Call once, at extension load: a value already stored * for this id survives, so re-declaring cannot reset a user's choice. */ declare(def: SettingDef): void get(id: string): T | undefined set(id: string, value: SettingValue): Promise /** * Watches a setting, including one the pack did not declare. * * `declare`'s own `onChange` only fires for settings the pack owns, so a * pack that needs to react to a *core* preference — colour palette, link * render mode, locale — had nothing to observe and polled or ignored it. * * Fires on change only, not on registration. Returns a function that stops * watching; call it from wherever the pack tears down. */ onChange( id: string, listener: (value: T | undefined, previous: T | undefined) => void ): Unsubscribe } // ─── slotHandle.ts ─────────────────────────────────────────────── export interface LinkInfo { readonly id: string readonly sourceNodeId: string readonly sourceSlotId: SlotId readonly targetNodeId: string readonly targetSlotId: SlotId readonly type: string /** Position at snapshot time. Do not store across mutations. */ readonly sourceIndex: number readonly targetIndex: number } /** * Fields a pack may change on an existing slot. * * Applied atomically as one command, so a retype-plus-rename is a single undo * step rather than two. Retyping deliberately **keeps existing links**: dynamic * retyping (`*` -> `MODEL`) is the whole point for `SetNode`-style packs, and * silently dropping connections is the failure mode this API exists to end. * * @knipIgnoreUnusedButUsedByCustomNodes */ /** * A slot's type, which may be a union. * * An array spells "this slot accepts any of these" — rgthree's * `addInput('input', ['IMAGE', 'LATENT', 'MASK'])` is the shipped example, so * packs do write it even though litegraph's own `ISlotType` says * `number | string`. * * Both forms are accepted and stored as the comma string, because that is what * litegraph compares against: it normalises with `String(type).split(',')`, so * `['IMAGE','LATENT','MASK']` and `'IMAGE,LATENT,MASK'` are the same slot to * every connection check. The saved workflow therefore holds the string where * the original held an array — a byte difference with no behavioural one, and * the same call already taken for slot `shape`. * * Reads stay `string` for the same reason. * @knipIgnoreUnusedButUsedByCustomNodes */ export type SlotType = string | string[] /** @knipIgnoreUnusedButUsedByCustomNodes */ export type SlotDirection = 'none' | 'up' | 'down' | 'left' | 'right' | 'center' /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SlotPosition { readonly x: number readonly y: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SlotPatch { name?: string label?: string | undefined /** The backend-provided translated caption. Null clears it. */ localizedName?: string | null type?: SlotType /** Slot centre relative to the node body. Null restores automatic layout. */ position?: SlotPosition | null /** Direction in which links leave the slot. Null restores the default. */ direction?: SlotDirection | null /** * The dot's colour when connected and when not. * * Not decoration, despite appearances: both sit on `INodeSlot` and * `ISerialisableNodeInput` omits only `boundingRect`, `widget` and `link`, * so they are written into the saved workflow. A pack that coloured its * slots and then stopped saves different bytes than it used to. * * `null` clears one back to the renderer's default. */ color?: string | null colorWhenUnconnected?: string | null /** * Sits on the same `INodeSlot` as the colours above and is omitted by the * same `Omit`, so the argument made for them holds verbatim: a pack that * shaped its slots and then stopped saves different bytes than it used to. * * `'default'` clears it back to the renderer's own choice. */ shape?: SlotShape } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface InputSlotPatch extends SlotPatch { /** Retargets the widget this input is the socket form of. Null clears it. */ widget?: string | null /** Replaces the input declaration used by connected Primitive nodes. */ widgetConfig?: InputWidgetConfig } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface InputWidgetConfig { /** Backend input type, or the choices for a COMBO input. */ readonly type: string | readonly (string | number)[] readonly options?: Readonly> } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SlotSnapshot { readonly id: SlotId readonly index: number readonly name: string readonly type: string readonly label: string | undefined readonly localizedName: string | undefined readonly position: SlotPosition | undefined readonly direction: SlotDirection | undefined readonly shape: SlotShape readonly isConnected: boolean } /** @knipIgnoreUnusedButUsedByCustomNodes */ export type ResolvedInputSource = | { readonly kind: 'output' readonly graphId: string readonly nodeId: string readonly outputIndex: number } | { readonly kind: 'literal'; readonly value: WidgetValue } | { readonly kind: 'omitted'; readonly reason: string } export interface InputSlotHandle { readonly id: SlotId /** Volatile — shifts when other slots are added or removed. */ readonly index: number readonly name: string readonly type: string readonly label: string | undefined readonly isConnected: boolean /** The type arriving through the link, including across a subgraph input. */ readonly connectedType: string | undefined /** Whether this input is the socket form of a widget. */ readonly isWidgetInput: boolean /** The declaration a connected Primitive node renders. */ widgetConfig(): Readonly | undefined /** Intersects this input's declaration with another compatible one. */ mergeWidgetConfig( config: InputWidgetConfig ): Readonly | undefined link(): LinkInfo | undefined source(): { nodeId: string; outputIndex: number } | undefined /** * What ultimately feeds this input after frontend nodes resolve. * * `source()` reports the physical link, which is right for editing topology. * This reports the executable source through reroutes, Get/Set nodes and any * other frontend node declared with `defs.define({ resolve })`. Resolution is * read-only and leaves the graph untouched. */ resolvedSource(): ResolvedInputSource | undefined disconnect(): boolean modify(patch: InputSlotPatch): void /** Replaces `{...input}`, which now yields nothing useful. */ snapshot(): Readonly } export interface OutputSlotHandle { readonly id: SlotId readonly index: number readonly name: string readonly type: string readonly label: string | undefined readonly isConnected: boolean /** Frozen snapshot — safe to iterate while disconnecting. */ links(): readonly LinkInfo[] targets(): readonly { nodeId: string; inputIndex: number }[] connectTo(targetNodeId: string, input: SlotRef): LinkInfo | undefined disconnect(targetNodeId?: string): boolean modify(patch: SlotPatch): void /** * Moves every link on this output to another output of the same node, * **preserving link ids**. * * Disconnect-and-reconnect is not equivalent: it allocates new ids, so the * serialized workflow changes. Packs that re-home their own outputs during a * migration depend on identity being kept. * * Slot types are **not** re-validated. The real-world sequence moves links * off an output and then retypes it, so enforcing compatibility mid-move * would reject exactly the case this exists for. */ moveLinksTo(target: SlotRef): readonly LinkInfo[] snapshot(): Readonly } export interface SlotCollection { readonly length: number get(ref: SlotRef): THandle | undefined byId(id: SlotId): THandle | undefined byName(name: string): THandle | undefined /** Explicit positional access. */ at(index: number): THandle | undefined all(): readonly THandle[] ids(): readonly SlotId[] names(): readonly string[] /** * Adds a slot. 18 packs grow their inputs as the last one fills — the * "Multi" combiner pattern — which needed `node.addInput` until now. * * `shape` is not decoration: it is written into the saved workflow, so a * slot added without the one its pack used to set serialises differently * from one the pack itself wrote. `'optional'` is the hollow circle * ComfyUI draws for an input that need not be connected. */ add(name: string, type: SlotType, options?: SlotOptions): THandle /** * Removes a slot by reference. Any link into it is dropped, as it would be * on the legacy path. */ remove(ref: SlotRef): boolean /** * Puts the slots in the given order. `names` must be a permutation of the * current ones. * * Every link into or out of this node is re-pointed as part of the move, in * one batch, so link ids — and therefore the saved workflow's `links` array * — are unchanged. That is the whole reason this exists rather than being * left to packs: a link stores its endpoint as a slot *index*, so a pack * permuting the array itself silently re-points every connection, and the * damage only shows when the workflow is next run. * * The slot *order* is serialized, so this changes the saved file by design — * it is how a pack keeps its dynamic inputs matching what the backend * declares. */ reorder(names: readonly string[]): void [Symbol.iterator](): Iterator } /** * How a slot is drawn, which ComfyUI overloads to mean how it behaves. * * Named rather than numbered: packs wrote `{ shape: 7 }`, and 7 is meaningless * without litegraph's RenderShape enum in front of you. */ export type SlotShape = 'default' | 'optional' | 'list' | 'directional' /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SlotOptions { /** * `'optional'` is the hollow circle for an input that need not be connected, * `'list'` the grid ComfyUI draws for an output that yields many values, and * `'directional'` the arrow a pack uses for a slot that only ever feeds one * particular kind of node. */ shape?: SlotShape localizedName?: string position?: SlotPosition direction?: SlotDirection /** * Names the widget this slot is the socket form of — the "convert widget to * input" shape. * * Not decoration either: a slot carrying it serialises as * `{ widget: { name } }` where a plain socket serialises as `{ pos }`, and * the widget keeps its place in `widgets_values`. A dynamic input added * without it changes the saved file. */ widget?: string /** The declaration a connected Primitive node should render. */ widgetConfig?: InputWidgetConfig } // ─── slotRef.ts ────────────────────────────────────────────────── export type SlotId = string & { readonly __brand: 'SlotId' } /** * A slot reference: a string (id or name), or an explicit `{ index }`. * * A bare `number` is deliberately not accepted so positional access is visible * at the call site and greppable: * * output.connectTo(node, 'image') // by name — preferred * output.connectTo(node, { index: 0 }) // by position — explicit */ export type SlotRef = SlotId | string | { readonly index: number } export interface ResolveOptions { /** * Whether the backend supplies slot names yet. While false, a canonical * integer string resolves positionally, so `'0'` addresses slot 0 and call * sites need no rewrite once names arrive. * * Retire this together with the release that ships names — until then a pack * passing `'2'` meaning a name would silently bind slot 2. */ readonly namedSlotsAvailable: boolean } // ─── storageHandle.ts ──────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface StorageUsage { /** Total bytes stored under the namespace. */ readonly usedBytes: number /** How many entries make up {@link usedBytes}. */ readonly entryCount: number /** * The ceiling this host enforces, or `undefined` where it enforces none. * * Undefined is the honest answer for a local install with the user's own * disk behind it, and it is deliberately not reported as `Infinity`: a pack * dividing by it to draw a gauge would get a meaningless bar rather than the * chance to skip drawing one. Do not treat a present number as a promise * that a write below it succeeds — another namespace shares the same store. */ readonly quotaBytes?: number } export interface StorageHandle { /** * Names stored under a namespace, which must be one this pack owns. * * Empty when nothing has been stored yet — absence is not an error. */ list(namespace: string): Promise /** The stored text, or `undefined` if there is none. */ get(name: string): Promise set(name: string, value: string): Promise remove(name: string): Promise /** * What a namespace currently occupies. * * For a pack that stores things a user accumulates — presets, captions, * saved prompts — so it can show what it is holding and offer to prune it, * rather than growing without bound until someone else's write fails. */ usage(namespace: string): Promise } // ─── systemHandle.ts ───────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SystemMonitorCpu { readonly utilization_percent: number | null } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SystemMonitorMemory { readonly total: number readonly available: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SystemMonitorVolume extends SystemMonitorMemory { readonly id: string readonly label: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SystemMonitorAccelerator { readonly id: string readonly name: string readonly memory_total: number readonly memory_available: number readonly utilization_percent: number | null readonly temperature_c: number | null } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SystemMonitorSnapshot { readonly cpu: SystemMonitorCpu readonly memory: SystemMonitorMemory readonly volumes: readonly SystemMonitorVolume[] readonly accelerators: readonly SystemMonitorAccelerator[] } export interface SystemHandle { /** * Returns one host-sampled hardware snapshot. Volume ids are opaque and * unsupported utilization or temperature sensors are null. */ monitor(): Promise } // ─── uiHandle.ts ───────────────────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface SidebarTabBase { /** * Unique across every pack, so namespace it — `'mtb.assets'`, not * `'assets'`. Registering an id twice throws rather than silently replacing * the other pack's tab. */ readonly id: string readonly title: string /** * An iconify class, e.g. `'icon-[lucide--activity]'`. Omit for no icon. */ readonly icon?: string readonly tooltip?: string } /** * A tab the pack draws into a container itself. * * Framework-agnostic, and the only form available to a pack that ships * hand-written ES modules with no build step — which is most of them. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface MountedSidebarTab extends SidebarTabBase { /** * Fills the tab's panel. Called each time the tab becomes visible, so treat * it as mount rather than as one-time setup, and put teardown in `destroy`. */ render(container: HTMLElement): void /** Releases what `render` retained — listeners, timers, observers. */ destroy?(): void } /** * A tab that is a Vue component, mounted and torn down by the host. * * The preferred form where a pack can build. It keeps reactivity, scoped * styles and `onUnmounted`, and the host mounts and unmounts it. * * Per ADR 0005 the pack bundles its own Vue (~30KB gzipped) — there is no * import map, so `import { defineComponent } from 'vue'` resolves at the * pack's build time, not ours. That is a second Vue instance on the page, * which the ADR weighed and accepted; nothing is shared across the boundary, * so the two runtimes never touch. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface VueSidebarTab extends SidebarTabBase { readonly component: VueComponent } /** @knipIgnoreUnusedButUsedByCustomNodes */ export type SidebarTabDef = MountedSidebarTab | VueSidebarTab /** A Vue component bundled by the pack. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export type VueComponent = object /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface DialogBase { /** * Unique across every pack, so namespace it. The host prefixes it with * `extension-`, which keeps packs out of the internal dialog keyspace. */ readonly key: string readonly title?: string } /** A bounded keyboard event captured while a mounted dialog owns focus. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface DialogKeyEvent { readonly key: string readonly code: string readonly repeat: boolean readonly altKey: boolean readonly ctrlKey: boolean readonly metaKey: boolean readonly shiftKey: boolean /** True for an input, textarea, select, or editable content target. */ readonly editableTarget: boolean } /** A dialog the pack draws into a container itself. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface MountedDialog extends DialogBase { render(container: HTMLElement): void /** Receives dialog-scoped key events even before a child takes focus. */ onKeyDown?(event: DialogKeyEvent): void | Promise destroy?(): void } /** A dialog that is a Vue component, mounted and torn down by the host. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface VueDialog extends DialogBase { readonly component: VueComponent readonly props?: Readonly> } /** @knipIgnoreUnusedButUsedByCustomNodes */ export type DialogDef = MountedDialog | VueDialog /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface DialogHandle { close(): void } export interface UiHandle { /** * Adds a tab to the sidebar. Returns a function that removes it again. */ addSidebarTab(def: SidebarTabDef): Unsubscribe /** * Shows a small readout in the top bar — a status, a count, a live metric. * * Replaces `app.menu.settingsGroup` and inserting an element next to * `.comfy-settings-btn`. Declarative on purpose: the pack says what to show * and the host renders it, in house style and at whatever size the viewport * allows. Nothing here takes an element, a class or a style, which is what * keeps the chrome ours to restyle. * * Returns a handle rather than an unsubscribe: for a value that changes, * call `update({ text })`. A closure would not work — the host renders when * reactive state changes and cannot see a plain function, so the readout * would show its first value forever. */ addTopBarBadge(badge: BadgeContribution): ChromeItemHandle /** * Adds a button to the action bar. `run` is called on click. * * For a pack that also wants a keyboard shortcut or a palette entry, * register a command and call it from `run`, rather than duplicating the * behaviour in both places. */ addActionBarButton( button: ButtonContribution ): ChromeItemHandle /** * Opens a modal dialog. Returns a handle that closes it again. * * Replaces `app.ui.dialog` and the `new app.ui.dialog.constructor()` idiom. * Several conversions hand-rolled a native `` or borrowed core's * `.comfy-modal` class names instead — the latter couples a pack to markup * we rename freely, so both are worth retiring. */ showDialog(def: DialogDef): DialogHandle /** * Shows a menu where the user clicked. * * `b.addMenuItem` is the node's own context menu — a different menu, on a * different target, opened by the host. This is for a menu a pack raises * itself: a lora row's Move Up / Remove, a chip that picks an output type. * Four files hand-rolled it by constructing the renderer's menu class * directly, which pins them to a renderer we intend to replace. * * Positioned from the event so the menu lands under the pointer, which is the * only placement that reads as a context menu. Arrow keys traverse nested * items, Enter or Tab selects one, and Escape closes the menu. */ showMenu(def: MenuDef): MenuHandle /** * Asks the user for a value. Resolves `undefined` if they cancel. * * Packs called `canvas.prompt(...)`, which draws a small field at the cursor * — clicking a lora's strength to type a new one. That field belongs to the * legacy canvas and the host itself no longer uses it; this is the prompt the * host does use, so a pack keeps the capability and loses only the placement. */ prompt(def: PromptDef): Promise } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface PromptDef { /** What is being asked for — "Strength", "Label". */ readonly label: string readonly value?: string readonly placeholder?: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface MenuItemDef { readonly label: string /** Shown but not selectable. */ readonly disabled?: boolean /** A nested menu. Mutually exclusive with {@link run}. */ readonly submenu?: readonly MenuItemDef[] run?(): void } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface MenuDef { readonly items: readonly MenuItemDef[] /** Shown above the items. */ readonly title?: string /** The event that asked for the menu; it decides where the menu appears. */ readonly event: MouseEvent } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface MenuHandle { close(): void } // ─── widgetHandle.ts ───────────────────────────────────────────── // `null` is included because core's own `WidgetValue` has it and // `addWidget('button', name, null, cb)` produced exactly that. Omitting it made // a null value inexpressible through the published API, so a converted button's // `widgets_values` entry changed and the saved workflow differed. export type WidgetValue = string | number | boolean | object | undefined | null /** Options understood by core or by a widget type declared by the pack. */ /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetOptions { readonly [key: string]: unknown readonly on?: string readonly off?: string readonly max?: number readonly min?: number readonly precision?: number readonly read_only?: boolean readonly step?: number readonly step2?: number readonly multiline?: boolean readonly property?: string readonly socketless?: boolean readonly canvasOnly?: boolean readonly hideInPanel?: boolean readonly nodeType?: string readonly serialize?: boolean readonly values?: unknown readonly iconClass?: string readonly disabled?: boolean readonly useGrouping?: boolean readonly placeholder?: string readonly showThumbnails?: boolean readonly showItemNavigators?: boolean readonly hidden?: boolean } /** * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract. * * Accessor methods rather than properties, so a read can be a store query and * a write can dispatch a command. */ export interface WidgetHandle extends HandleCommon { readonly name: string readonly widgetType: string getValue(): T /** * Commits a value exactly as a user edit does: the value is written, a * widget bound to a node property syncs it, the widget's callback chain and * the node's `onWidgetChanged` run, and `graph.version` advances. This * replaces the manual pair `widget.value = x; widget.callback?.(x)` — and * the bare write too, because a write the rest of the system cannot see was * never a feature, it was litegraph defaulting to inconsistency. * * Writing the current value again is a no-op, which is also what ends a * cycle of handlers writing to each other. `on('change')` fires once per * commit; `on('activate')` does not fire, because activate reports a user's * act. */ setValue(value: WidgetValue): void /** * The widgets core attached to this one — a seed's `control_after_generate`, * a bounding box's components. * * `setHidden` already cascades through these, so hiding needs no call here. * What does is reading one: a pack asks a seed's control widget whether it * says `fixed` or `randomize` to know what the node will do next. */ linked(): readonly WidgetHandle[] /** * Replaces the controls attached to this widget. * * Core uses this relationship for compound inputs: hiding a seed also hides * its `control_after_generate` picker. Packs build the same compound control * when they add a random-seed button or an index policy, and assigning * `linkedWidgets` directly was the only way to make conversion-to-input hide * the whole unit. * * Every name must identify another widget on this node. Pass an empty array * to clear the relationship. */ setLinked(names: readonly string[]): void isHidden(): boolean /** * Replaces the `type = 'converted-widget'` hack. Value is retained. * * Cascades to the widgets core attached to this one — a seed's * `control_after_generate`, a bounding box's components. The legacy * `hideWidget` helper this replaces recursed through `linkedWidgets`, and * packs that lost the cascade were left with an orphaned control widget * floating where its owner used to be. */ setHidden(hidden: boolean): void getOptions(): Readonly | undefined setOption(key: string, value: unknown): void setLabel(label: string): void isDisabled(): boolean setDisabled(disabled: boolean): void isSerialized(): boolean /** The height the host most recently allocated, or undefined before layout. */ getHeight(): number | undefined /** * Pins the widget's height in graph units, instead of letting it share * whatever space the node has spare. * * The node divides free height between every widget that does not state one, * so a node carrying two mounted strips gave each half the node however * small they were meant to be. `MountDef.height` does not do this — it sets * the container's CSS height *inside* an allocation the renderer already * chose, which is why a fixed strip still drifted. * * Replaces re-assigning `node.computeSize`, which is what packs did and * which is not published. Omit it for a panel meant to fill the node: the * growable path is the one that fills. */ setHeight(px: number): void /** * Replaces capture-and-chain on `widget.callback`, which 1,000+ sites do and * which silently drops an earlier pack's listener whenever one forgets to * call through. Listeners here are additive and independent. */ on( event: 'change', listener: (value: WidgetValue, oldValue: WidgetValue) => void ): Unsubscribe on(event: 'removed', listener: () => void): Unsubscribe /** * The widget was activated — a button click, or a value committed. * * Buttons carry no value, so `change` can never fire for one and a button * created through this API would otherwise be inert. Prefer `change` when you * care about the value; use this when you care that the user acted — a * programmatic `setValue` never fires it. */ on(event: 'activate', listener: (value: WidgetValue) => void): Unsubscribe /** * Contributes behavior to a host-owned multiline text editor without exposing * its DOM. The event reports the live value and caret on each input, * selection change, or wheel gesture; its write method preserves both the * widget commit protocol and the requested selection. */ on( event: 'textInteraction', listener: (event: WidgetTextInteractionEvent) => void ): Unsubscribe /** * The value is about to be written out, and may be replaced for this * destination only. * * This is what `widget.serializeValue` did, and the reason it is back: a * static `serialize` flag can only *suppress* a value, and a whole class of * packs needs to *supply* a different one. rgthree's Seed keeps the sentinel * `-1` in the saved workflow and sends the rolled seed; pysssss' PresetText * expands `@name` into the queued prompt while the user keeps seeing the * reference; Impact Pack embeds image data the canvas never shows. * * `context` says which destination is being built, because those packs want * to change one and not the other: * * - `'workflow'` — the file the user saves. * - `'prompt'` — the queued API payload the backend executes. * - `'embedded'` — the copy of the workflow that travels with that prompt * and is written into the output image. Distinct from `'workflow'` * because a pack may want the image to reproduce the run while the saved * file keeps its sentinel: rgthree's Seed saves `-1` but embeds the seed * it actually rolled, so dragging the PNG back in reproduces it. * * A handler that ignores `context` changes all three. * * Calling `setSerializedValue` replaces the value for this write only; the * widget itself is untouched, so the user still sees what they typed. Last * handler to call it wins. */ on( event: 'beforeSerialize', listener: (event: WidgetSerializeEvent) => void ): Unsubscribe } /** Where a value is being written, and the chance to change it. */ export interface WidgetSerializeEvent { readonly context: 'workflow' | 'prompt' | 'embedded' /** What would be written if no handler intervened. */ readonly value: WidgetValue setSerializedValue(value: WidgetValue): void } export type Unsubscribe = () => void /** * A widget whose body the pack renders itself. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface MountDef { readonly name: string /** * Fills the mounted container. Called once, with an element already attached * to the node. * * `value` holds meaningful serialized state only when `defaultValue` was * given. A decorative mount receives the same accessor for one render shape, * but should not use it as storage. */ render(container: HTMLElement, value: MountedValue): void /** Releases anything `render` retained — listeners, timers, observers. */ destroy?(): void /** Reserved height in graph units. Omit to size to content. */ readonly height?: number /** Set false to keep the element rendered at low zoom. Defaults to true. */ readonly hideOnZoom?: boolean readonly hidden?: boolean /** * Whether the value is written into the saved workflow. * * Defaults to `true` when `defaultValue` makes this a value-holding control, * and to `false` for a decorative mount. */ readonly serialize?: boolean /** * Whether the value is sent in the API prompt. Defaults to `serialize`. * * These are two different flags in litegraph — `widget.serialize` gates the * saved workflow, `options.serialize` gates the prompt — and collapsing them * into one boolean made two states unsayable. "Saved but not sent" is the * one packs need: it is exactly what the legacy * `addDOMWidget(…, { serialize: false })` did, and a readout that a node * fills in from its own execution result belongs in the workflow but has no * business appearing as an input on the next queue. * * Set it apart from `serialize` only when the two genuinely differ. */ readonly sendToPrompt?: boolean /** * Makes this a value-holding widget rather than decoration. * * Without it a mount is a drawing: it can occupy a `widgets_values` slot but * has nothing to put in it, so a colour picker or a text box converted onto * `mount` kept its position and silently lost what the user typed. Supplying * a default gives the widget a real cell, reachable through `render`'s second * argument. */ readonly defaultValue?: MountedData } /** What a mounted control can hold. @knipIgnoreUnusedButUsedByCustomNodes */ export type MountedData = string | number | boolean | object | null /** * Reading and writing a mounted widget's value. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface MountedValue { get(): MountedData set(value: MountedData): void /** Notified when the value changed elsewhere — a workflow load. */ onChange(listener: (value: MountedData) => void): Unsubscribe } /** * A pointer event on the widget's own canvas, in the same units `draw` uses. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface CanvasPointerEvent { /** Distance from the canvas's left edge, in CSS pixels. */ readonly x: number /** Distance from its top edge, in CSS pixels. */ readonly y: number /** The DOM event, for modifier keys, `button`, and `preventDefault()`. */ readonly event: PointerEvent } /** * The colours a pack should draw its own controls in. * * Published because we told packs to draw. A widget that hardcodes its palette * looks wrong the moment the user switches theme, and the alternative — reading * `LiteGraph.WIDGET_BGCOLOR` and friends — is a renderer constant we intend to * delete. These are the design system's own tokens, resolved from the widget's * computed style, so they follow the theme without the pack knowing which one * is active. * * Named by intent rather than by token, because the token names will churn and * a pack should not have to follow. Re-read on every draw, so a theme switch * needs nothing from the pack. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface CanvasTheme { /** A control's background. */ readonly surface: string /** The same under the pointer. */ readonly surfaceHovered: string /** A control's outline. */ readonly border: string /** A label. */ readonly text: string /** A value, a unit, anything the label outranks. */ readonly textSecondary: string } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface CanvasDef { readonly name: string /** Reserved height in pixels. Omit to size to the node's width. */ readonly height?: number draw( context: CanvasRenderingContext2D, size: readonly [number, number], theme: CanvasTheme, value: MountedValue | undefined ): void /** * The pointer went down on this widget. * * Coordinates are relative to the canvas and in the same units `draw` * receives, so a hit test written against the drawing works unchanged — * which is the point. A pack that drew its own controls keeps both the * drawing and the hit testing; only the surface changes, from the host's * canvas to its own. * * The primary button is taken: it stops here rather than also reaching the * node, or adjusting a slider would drag the node underneath it. Middle and * right are left alone, so panning and the context menu still work over the * widget. * * The pointer is captured for the gesture, so a drag that leaves the widget * still reports moves and the release. */ onPointerDown?(event: CanvasPointerEvent): void /** Moves during a drag, and hover when no button is down. */ onPointerMove?(event: CanvasPointerEvent): void onPointerUp?(event: CanvasPointerEvent): void /** * The secondary button went down on this widget. * * Right-click is left alone by {@link onPointerDown} so the node's own * context menu keeps working over a widget, which is right by default and * wrong for a widget that has its own menu — a lora row wants Move Up, Move * Down, Remove. Declaring this claims the gesture: the browser menu is * suppressed and the node's does not open. */ onContextMenu?(event: CanvasPointerEvent): void /** * Makes the surface hold a value rather than only draw one. * * Without it a drawn control that stores something has to be two widgets — a * hidden value widget and a surface — and two widgets cannot occupy the one * position the original had. That is not a tidiness point: `serialize` writes * at each widget's own index and leaves a hole where a non-serializing widget * sits, so the pair has to be ordered value-first to keep the saved array * intact, and a pack that gets that wrong writes a null into every workflow * the node has ever appeared in. It moved rgthree's Power Puter chip row * below its code box. * * `draw` receives the current value as its fourth argument. */ readonly defaultValue?: MountedData /** Whether the value reaches the saved workflow. See {@link MountDef.serialize}. */ readonly serialize?: boolean /** Whether the value reaches the API prompt. See {@link MountDef.sendToPrompt}. */ readonly sendToPrompt?: boolean } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface CanvasHandle { readonly widget: WidgetHandle /** Redraws now. Call when the data behind the drawing changed. */ redraw(): void } /** Everything needed to create a widget. */ export interface WidgetDef { readonly type: string readonly name: string readonly value?: WidgetValue readonly options?: WidgetOptions /** Display-only widgets — replaces the readOnly/opacity DOM fiddling. */ readonly disabled?: boolean readonly hidden?: boolean /** * Whether the value is written into the saved workflow. * * Replaces `widget.serializeValue = async () => {}`, the idiom packs use to * keep a derived readout out of `widgets_values`. Orthogonal to `hidden`. */ readonly serialize?: boolean } export interface WidgetCollection { readonly length: number get(name: string): WidgetHandle | undefined at(index: number): WidgetHandle | undefined all(): readonly WidgetHandle[] names(): readonly string[] /** * Replaces splice/assign reordering. `names` must be a permutation of the * current names — a partial list throws rather than silently dropping * widgets, which is how the array-splice idiom lost them. */ reorder(names: readonly string[]): void move(name: string, toIndex: number): void /** * Creates a widget on this node. * * The counterpart to `remove` — packs that rebuild a readout widget do * remove-then-create, and without this only half the operation has a * destination, which makes the conversion cosmetic. */ add(def: WidgetDef): WidgetHandle /** * Mounts an element on the node and hands it to the pack to fill. * * The replacement for `addDOMWidget`, and the destination for hand-painted * canvas controls. Across kjnodes' canvas editors the drawing is rectangles, * images, straight lines and text — all DOM primitives — but a pack that * wants to keep its existing `ctx` code can append a `` to the * container and carry it over unchanged. * * The gain is not the drawing, it is the input: these editors hand-roll * hit-testing against bounding boxes because canvas gives them nothing to * attach a listener to. Mounted in the DOM, pointer events land on the * element and most of that code goes away. */ mount(def: MountDef): WidgetHandle /** * A per-node drawing surface, and the destination for `onDrawForeground`. * * Works under both renderers without the pack knowing which it is on: the * canvas is a DOM element, which the legacy renderer positions over the * graph canvas and Nodes 2.0 renders directly. That is the whole reason it * is a mounted element rather than a hook into the graph's own context — * drawing into the shared context is what ties a pack to the old renderer. * * `draw` is called on mount, on resize, and whenever `redraw()` is called. */ canvas(def: CanvasDef): CanvasHandle remove(name: string): boolean [Symbol.iterator](): Iterator } export interface ComboPreviewRegistration { /** Namespaced registration id. */ readonly id: string /** Managed model catalogues searched in order. */ readonly modelCategories: readonly ( | 'loras' | 'checkpoints' | 'unet' | 'diffusion_models' )[] /** Model filename suffixes that activate this policy. */ readonly extensions: readonly ( | 'safetensors' | 'sft' | 'pt' | 'ckpt' | 'gguf' )[] /** Host-owned adjacent-preview lookup policy. */ readonly candidatePolicy: 'adjacent-model-preview-v1' /** Preview media types the host may display. */ readonly media: readonly ( | 'image/png' | 'image/webp' | 'image/jpeg' | 'video/mp4' | 'video/webm' )[] } export interface ComboPreviewAssignment { /** Managed model catalogue containing `modelValue`. */ readonly category: 'loras' | 'checkpoints' | 'unet' | 'diffusion_models' /** Logical model filename from the managed combo; never a host path. */ readonly modelValue: string /** Graph node whose host-owned output image is used as the preview. */ readonly sourceNodeId: string /** Exact image in that node's current host-owned output list. */ readonly imageIndex: number readonly policy: 'adjacent-model-preview-v1' } export interface WidgetsHandle { /** * Adds a declarative preview policy to host-owned combo option menus. * The host resolves managed assets and renders the hover surface; the pack * receives neither filesystem paths nor media URLs. */ registerComboPreview(definition: ComboPreviewRegistration): Unsubscribe /** * Re-encodes one managed graph output as an adjacent managed-model preview. * The host resolves both resources; the pack receives no path or image bytes. */ assignComboPreview(assignment: ComboPreviewAssignment): Promise } export type LocalizationMessage = | string | null | { readonly [key: string]: LocalizationMessage } export interface LocalizationCatalog { /** Native vue-i18n-shaped messages such as main/nodeDefs/nodeCategories. */ readonly messages: Readonly> /** Exact-source fallback translations used only at host-owned render points. */ readonly phrases?: Readonly> } export interface LocalizationHandle { /** * Contributes one bounded catalog for a host-supported locale. The host * owns merging, rendering, precedence, and cleanup; no DOM access is given. */ registerCatalog(locale: string, catalog: LocalizationCatalog): Unsubscribe } // ─── widgetTextInteraction.ts ──────────────────────────────────── /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTextSelection { readonly start: number readonly end: number } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTextEventBase { readonly value: string readonly selection: WidgetTextSelection /** Positions a host menu at the text editor without exposing its element. */ readonly menuEvent: MouseEvent /** Commits through the widget protocol and optionally restores the caret. */ setValue(value: string, selection?: WidgetTextSelection): void focus(): void } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTextInputEvent extends WidgetTextEventBase { readonly kind: 'input' | 'selection' } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTextWheelEvent extends WidgetTextEventBase { readonly kind: 'wheel' readonly deltaY: number readonly ctrlKey: boolean /** Claims the wheel gesture so the canvas does not pan or zoom. */ preventDefault(): void } /** @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTextKeyEvent extends WidgetTextEventBase { readonly kind: 'keydown' readonly key: string readonly ctrlKey: boolean readonly altKey: boolean readonly shiftKey: boolean readonly metaKey: boolean readonly repeat: boolean preventDefault(): void stopPropagation(): void } /** * An interaction with a host-owned multiline text editor. * * This is the renderer-independent replacement for reaching through * `widget.inputEl`: packs can inspect the live caret, offer a menu through * `menuEvent`, replace text, and implement selection-based wheel edits without * receiving the host's element or markup. */ export type WidgetTextInteractionEvent = | WidgetTextInputEvent | WidgetTextWheelEvent | WidgetTextKeyEvent // ─── widgetTypes.ts ────────────────────────────────────────────── /** What a pack-declared widget can hold. */ export type WidgetTypeData = string | number | boolean | object | null /** * Reading and writing the widget's value, for the renderer to bind to. * * @knipIgnoreUnusedButUsedByCustomNodes */ export interface WidgetTypeValue { get(): WidgetTypeData set(value: WidgetTypeData): void /** Notified when the value changes for any other reason — a workflow load. */ onChange(listener: (value: WidgetTypeData) => void): Unsubscribe } export interface WidgetTypeContext { /** A frozen snapshot of the input declaration's current options. */ getOptions(): Readonly> /** * Runs while the widget's owning node belongs to a graph. * * Widget constructors run before a node has an id or graph, so a node handle * cannot be supplied directly to `render`. The listener runs after the node * joins a graph and tears down when it leaves. */ onNodeReady(listener: (node: NodeHandle) => Unsubscribe | void): Unsubscribe } export interface WidgetTypeDef { /** Used when the definition supplies none. */ readonly defaultValue?: WidgetTypeData /** Height in pixels. Omit to size to content. */ readonly height?: number /** Smallest width the control needs, in pixels. */ readonly minWidth?: number /** Smallest height the control needs, in pixels. */ readonly minHeight?: number /** * Whether the value is saved and sent. Defaults to `true`: this widget holds * a real input value, unlike a mounted decoration. */ readonly serialize?: boolean /** * Fills the container. Return a teardown if the control owns listeners, * timers or observers. * * `name` is the input being rendered — controls commonly label themselves * with it, which a type-level renderer otherwise has no way to know. */ render( container: HTMLElement, value: WidgetTypeValue, name: string, context: WidgetTypeContext ): Unsubscribe | void } // ─── workflowHandle.ts ─────────────────────────────────────────── /** Parsed ComfyUI workflow JSON. */ export type WorkflowData = Readonly> export interface WorkflowImportContext { readonly name: string readonly type: string } export type WorkflowImportResult = | { readonly workflow: WorkflowData | string } | { readonly prompt: Readonly> | string } export interface WorkflowImporter { /** Namespaced and unique within the pack. */ readonly id: string readonly mimeTypes?: readonly string[] readonly extensions?: readonly string[] /** Per-file limit; the host-wide ceiling is 16 MiB. */ readonly maxBytes: number enabled?(): boolean | Promise parse( bytes: Uint8Array, context: WorkflowImportContext ): | WorkflowImportResult | null | undefined | Promise } export interface WorkflowHandle { /** Replaces the active document with parsed ComfyUI workflow JSON. */ open(data: WorkflowData): Promise /** Returns the current saved-format workflow, bounded to 8 MiB. */ snapshot(): Promise /** Registers a bounded worker-side parser for host-opened or dropped files. */ registerImporter(importer: WorkflowImporter): Unsubscribe /** Expands the active document's `%date:...%` and `%Node.widget%` tokens. */ applyTextReplacements(value: string): string /** * The active document's identity: a process-local id minted fresh each time * a workflow finishes loading — including a second load of the same file, * which gets a different id from the first. `undefined` before the first * workflow has loaded this page load. * * Distinct from the workflow's own saved identity (its file path, or the * `id` written into the workflow JSON): that one is meant to survive a * reload and compare equal across sessions. This one is the opposite by * design — it exists so a pack can tell "the document I was looking at got * replaced" from "the document I was looking at got edited", which * comparing graph contents cannot do, since editing IS mutating the graph * contents of the very document that is still current. * * Equivalent to `current()?.id`, and kept because reading the id is the * common case and does not need a handle. */ documentId(): string | undefined /** * The document on screen, or `undefined` before one is open. * * A handle rather than the bare id when a pack needs to know what it is * looking at — the name to label its own UI, whether there are unsaved * edits, and whether a document it stored state for is still open. * * Read-only: opening has its own explicit call, and saving, closing and * renaming belong to the user. */ current(): DocumentHandle | undefined }