Files

8.8 KiB

ComfyUI node graphs in ten minutes

Read this first if you have not converted a pack before. It exists because the alternative is inferring the model from the pack's source, which is slow and tends to produce a plausible but wrong mental picture.

What the thing is

ComfyUI is a node graph editor whose graph is a program. The user wires nodes together; the frontend compiles that wiring into a JSON payload; a Python backend executes it and streams results back.

user edits graph  →  graphToPrompt()  →  POST /prompt  →  backend executes
       ▲                                                        │
       └────────────── node.onExecuted(message) ◀───────────────┘

Two artifacts come out of the same graph, and confusing them is the single most common conversion error:

Workflow Prompt
Purpose what the user saves and reloads what the backend runs
Contains positions, colours, titles, collapsed state, widget values node inputs and links only
Produced by graph.serialize() graphToPrompt()
Frontend-only nodes present must be resolved away

Both must come out byte-identical after a conversion. That is the hard constraint: a user's saved file and the job they queue cannot change.

What a node actually is

A node exists in two halves.

The backend half is a Python class. It declares its inputs, outputs and category, and the server sends that declaration to the frontend as a node definition — nodeData in the old hook. This is a description, not a node.

The frontend half is a JavaScript class generated from that definition, registered by type name. Every node the user drops on the canvas is an instance of it.

node definition (from backend)  →  generated class  →  instances on the canvas
   "KSampler", inputs, outputs        KSampler            node #7, node #12

So there are three distinct things, and packs act on all three:

  • the definition — before any class exists
  • the class — affects every instance of that type
  • the instance — one node on one canvas

A conversion that moves code between these levels changes behaviour. Watch for it: nodeData.name is a definition; this inside onNodeCreated is an instance.

The lifecycle

Everything a pack hooks hangs off this sequence:

  1. Definitions arrive from the backend.
  2. beforeRegisterNodeDef — for each definition, every extension gets a chance to modify the definition and patch the about-to-be-registered class. This is where nearly half of all packs do their work.
  3. registerNodeType puts the class in the registry under its type name.
  4. Instance created — user drops a node, or a workflow loads one. onNodeCreated fires (before the node has an id or a graph).
  5. onAdded — the node joins the graph. Now it has an id and is addressable. This is where the published onCreated fires, deliberately.
  6. onConfigure — only for nodes loaded from a saved workflow; restores widget values and any pack-specific state.
  7. onExecuted(message) — backend produced output for this node.
  8. onRemoved — node deleted.

Inputs, outputs, widgets

  • Inputs / outputs are sockets. Links connect an output to an input.
  • Widgets are the controls drawn on the node — a seed number, a sampler dropdown, a text box. A widget holds a value that becomes an input at execution time.

The wrinkle: a widget can be promoted to a socket so another node can drive it. Historically the frontend faked this by setting widget.type = 'converted-widget' and stashing the old type — a hack that packs learned to detect and imitate. It is now a real property. When you see 'converted-widget', the pack is almost always trying to hide a widget, not change its kind.

widgets_values in a saved workflow is a positional array — index matters, names are not stored. This is why widget order and count are part of the wire format, and why removing a widget is not a cosmetic change.

Why packs customise the frontend at all

Packs are not being gratuitous. There are a handful of recurring motives, and recognising which one you are looking at usually tells you the replacement. Counts are sites across the ~5,000-pack registry corpus; a pack often has several motives at once.

Motive What it looks like Scale
Show backend output on the node — text, previews, progress patch onExecuted, create a display widget 497 packs
Set up per-instance state — dynamic inputs, defaults, DOM patch onNodeCreated 943 packs
Restore that state on load patch onConfigure 429 packs
React to wiring — add a slot when the last one fills patch onConnectionsChange 223 packs
Draw on the node — badges, overlays, custom controls patch onDrawForeground 199 packs
Define frontend-only nodes — reroutes, switches, note nodes registerCustomNodes, isVirtualNode 86 packs
Change what gets saved or queued patch serialize, serializeValue, graphToPrompt fewer, highest risk

The last row is where conversions do damage, because it is the row that touches the wire format.

Why any of this needs converting

Almost all of the above is done by monkey-patching the generated class's prototype:

const original = nodeType.prototype.onExecuted
nodeType.prototype.onExecuted = function (message) {
  original?.apply(this, arguments) // ← if you forget this, you break other packs
  myBehaviour.call(this, message)
}

Three things are wrong with this, and they motivate the whole published API:

  1. It reaches into internals. nodeType.prototype, node.widgets, link.origin_id are implementation, and they are being reshaped.
  2. It does not compose. Whether your handler survives depends on every other pack remembering to call through. One that forgets silently disables yours, and load order decides who wins.
  3. It cannot be undone. There is no unpatch, so nothing can be torn down.

The published API replaces each of these with something registered rather than patched: handlers are additive, ordered, and individually removable, and the entity classes stay closed.

The mental model to convert with

A pack declares what it wants for which node types, and reacts to a small set of lifecycle events using handles that expose behaviour but not internals.

Concretely, the shape of nearly every conversion is:

// before: run for every type, filter, patch the prototype
app.registerExtension({
  name: 'x',
  beforeRegisterNodeDef(nodeType, nodeData) {
    if (nodeData.name !== 'MyNode') return // ← the selector
    const orig = nodeType.prototype.onExecuted // ← the chaining
    nodeType.prototype.onExecuted = function (m) {
      orig?.apply(this, arguments)
      populate.call(this, m.text) // ← the actual behaviour
    }
  }
})

// after: the selector is declared, the chaining disappears, behaviour is unchanged
comfy.defs.extend('MyNode', (b) => {
  b.onExecuted((node, result) => populate(node, result.text))
})

The guard clause becomes the selector. The capture-and-chain boilerplate goes away entirely. What is left is the behaviour, which you should be changing as little as possible.

Things that will mislead you

  • this is not always a node. Inside a patched prototype method it is an instance; inside beforeRegisterNodeDef it is not.
  • nodeData is a definition, not a node. It has no widgets and no id.
  • A widget named the same is not the same widget. Packs remove and recreate readout widgets on every execution.
  • app is the whole application. A pack reaching for app.graph usually wants its own node's graph, and often only needs the node.
  • Absence of a hook means nothing. Plenty of packs put their logic in a module-level side effect that runs at import.