mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-29 09:28:35 -05:00
557 lines
32 KiB
Markdown
557 lines
32 KiB
Markdown
---
|
||
name: converting-custom-nodes
|
||
description: 'Converts third-party custom-node JS off deprecated/unpublished ComfyUI APIs onto the published node API. Use for Magic Patch conversion work, migrating a pack, or reviewing a generated patch. Triggers on: convert custom node, magic patch, migrate pack, port to node API, output.links, input.link, widgets.splice, converted-widget.'
|
||
---
|
||
|
||
# Converting Custom Nodes
|
||
|
||
## Magic Patch workspace
|
||
|
||
For this portable conversion run, `v2/comfy-api.d.ts` is the exact published
|
||
API contract and `.magic-patch/references/` contains every deep dive linked
|
||
below. Paths into a ComfyUI frontend source checkout are background references
|
||
and may not exist. Do not leave the pack workspace or invent a member absent
|
||
from the bundled contract.
|
||
|
||
Converts third-party pack JS from the old, unpublished ComfyUI internals onto
|
||
the published node API (`src/platform/nodeApi/`, specified in `docs/node_api_WIP.md`).
|
||
|
||
This is **migration, not compatibility work**. The goal is that the old surface
|
||
can be _deleted_, so never add a shim — rewrite the call site.
|
||
|
||
## When to Use
|
||
|
||
- Converting a pack that Magic Patch escalated (`convert()` reported it in
|
||
`escalated`, meaning the mechanical rules deliberately refused).
|
||
- Hand-writing a conversion for an upstream PR.
|
||
- Reviewing a generated patch before it ships.
|
||
|
||
Do **not** use it to make broken code work again by any means available. A
|
||
conversion that reintroduces the old coupling is worse than no conversion.
|
||
|
||
## The two invariants
|
||
|
||
Everything below serves these. If a conversion violates either, it is wrong even
|
||
if the pack appears to work.
|
||
|
||
**1. The wire format must be byte-identical.**
|
||
`graphToPrompt` output and the serialized workflow must not change. This is the
|
||
frontend's contract with the backend, and it holds for 1,801/1,801 packs today —
|
||
which makes it an extremely sharp detector. The most common way to break it is
|
||
disconnect-and-reconnect, which allocates **new link ids**. Use
|
||
`output.moveLinksTo()` when re-homing links.
|
||
|
||
**2. Behaviour must be equivalent, except where the old code threw.**
|
||
The generated test has two halves: `equivalence` (must pass on the original
|
||
_and_ the converted source) and `fix` (must fail before, pass after). If you
|
||
cannot state an equivalence claim, you do not yet understand the code well
|
||
enough to convert it.
|
||
|
||
## Steps
|
||
|
||
### 1. Read what the code is actually doing
|
||
|
||
Do not pattern-match on the API name. The census surfaces are misleading:
|
||
|
||
- A `widgets.splice(i, 1)` immediately followed by `splice(i, 0, w)` is **not a
|
||
reorder** — it is a cache-invalidation hack. Replace it with
|
||
`widget.setOption(key, value)`, which invalidates properly.
|
||
- `onDrawForeground` is often **not drawing**. 47% of draw-callback bodies never
|
||
touch a drawing primitive; they enforce size, poll for changes, or sync DOM
|
||
visibility. Those become `setSizeConstraints`, `widget.on('change')`, and the
|
||
widget mount lifecycle respectively.
|
||
|
||
### 2. Check whether the object is live or serialized
|
||
|
||
**The single most dangerous confusion in this work.** These look identical:
|
||
|
||
```js
|
||
node.inputs[0].link = null // live slot — convert
|
||
p.workflow.nodes[i].inputs[0].link = null // serialized JSON — LEAVE ALONE
|
||
```
|
||
|
||
The second is correct as written; rewriting it corrupts a working pack. Trace
|
||
the variable to its origin before touching anything named `link`, `links`,
|
||
`inputs`, `outputs` or `widgets`. If it came from `graphToPrompt`, a fetch, a
|
||
`JSON.parse`, or a `.workflow` property, it is data — not a graph.
|
||
|
||
### 3. Apply the mapping
|
||
|
||
| Old | New | Notes |
|
||
| ---------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------- |
|
||
| `output.links.push(id)` | `output.connectTo(nodeId, inputRef)` | creating a connection |
|
||
| moving links between own outputs | `output.moveLinksTo(ref)` | **preserves link ids** — required for the wire gate |
|
||
| `output.links` (read) | `output.links()` | frozen snapshot, safe to iterate while disconnecting |
|
||
| `input.link = null` | `input.disconnect()` | check step 2 first |
|
||
| `input.link` (read) | `input.source()` / `input.isConnected` | |
|
||
| `node.type = x` | delete the line | usually a defensive no-op; true type replacement is a **gap** — punt it |
|
||
| `slot.type = x`, `slot.name = x` | `slot.modify({ type, name })` | atomic, one undo step; keeps existing links |
|
||
| `widget.type = 'converted-widget'` | `widget.setHidden(true)` | ⚠️ old hack also suppressed serialization — see `references/widgets.md` |
|
||
| `widgets.splice` to reorder | `widgets.reorder(names)` | throws on a partial list instead of dropping widgets |
|
||
| `widgets.splice(i,1)` + `splice(i,0,w)` | `widget.setOption(key, value)` | same index in/out = cache invalidation, **not** a reorder |
|
||
| `widgets.push(w)` | `widgets.add(def)` | |
|
||
| `widgets = [...]` / `widgets.length = n` | `widgets.remove(name)` | assignment drops renderer tracking; length skips teardown |
|
||
| `getCustomWidgets` POJO | `widgets.mount({ mount })` | |
|
||
| `{...input}` / `{...node}` | `.snapshot()` | accessors moved to the prototype, so spread yields nothing |
|
||
| `nodeType.prototype.onNodeCreated = ...` | `defs.extend(sel, b => b.onCreated(...))` | selector = the hook's existing guard clause |
|
||
| `nodeType.prototype.onExecuted = ...` | `b.onExecuted(node, result)` | see `references/node-definitions.md` |
|
||
| `nodeType.prototype.onConfigure = ...` | `b.onConfigured(node, data)` | |
|
||
| `nodeType.prototype.onRemoved = ...` | `b.onRemoved(node)` | |
|
||
| `widget.inputEl` | `widgets.mount({ render })` | the element arrives as `render`'s argument; there is no `inputEl` to read |
|
||
| `this.widgets.length = n` | remove by name | assigning length skips widget teardown |
|
||
| `+!!this.inputs[0].widget` | `input.isWidgetInput` | converted-widget sniffing |
|
||
| `onDrawForeground` (drawing) | `widgets.canvas({ draw })` | renders in canvas _and_ Nodes 2.0 |
|
||
| `onDrawForeground` (sizing) | `node.setSizeConstraints({ autoHeight })` | see `references/draw-callbacks.md` |
|
||
| `onDrawForeground` (polling) | `widget.on('change')` | pick the narrowest event |
|
||
| `extends LGraphNode` | `defs.define({ type, ... })` | virtual nodes use `resolve(ctx)` |
|
||
| `onConnectInput` returning `false` | `b.onBeforeConnect((node, e) => false)` | any listener refusing is enough |
|
||
| `getExtraMenuOptions` (node menu) | `b.addMenuItem({ label, run })` | entries accumulate; canvas/slot menus are still a **gap** |
|
||
|
||
Prefer `comfy.supports('...')` over version comparisons — see `docs/node_api_WIP.md` §2.
|
||
|
||
### 4. Watch for the frame-to-event shift
|
||
|
||
A draw callback recomputed from current state every repaint, so nothing ever
|
||
needed to announce a change. Declarations and decorations must be **set when the
|
||
value changes**. A port that calls `decorations.set()` from inside the old
|
||
callback body will appear to work and quietly run on every repaint forever.
|
||
|
||
### 5. Write the test before claiming success
|
||
|
||
State an equivalence claim and a fix claim. Run the file against both sources:
|
||
equivalence green on both, fix red on the original and green on the converted.
|
||
A fix-only test proves the conversion did _something_, not that it was safe.
|
||
|
||
### 6. Refuse when you should
|
||
|
||
Escalate or decline rather than guess when:
|
||
|
||
- You cannot tell whether the object is live or serialized (step 2).
|
||
- The replacement depends on intent you cannot recover — e.g. re-homing links
|
||
versus rebuilding a mirror have different correct answers.
|
||
- The published API has no destination. A missing API is a **core gap to file**,
|
||
not something to work around in a pack. `docs/node_api_WIP.md` §1 lists what is and is
|
||
not covered.
|
||
|
||
A refusal costs a round trip. A wrong rewrite of working code is invisible until
|
||
a user hits it.
|
||
|
||
A refusal must answer **why**. Keep one adjacent comment block that records:
|
||
|
||
1. the user-visible behavior;
|
||
2. the exact old mechanism;
|
||
3. the ownership, determinism, wire-format, renderer, scope or lifecycle
|
||
guarantee that makes the mechanism unacceptable;
|
||
4. the supported remainder and exact user loss (`INOPERABLE: nothing` when
|
||
there is none); and
|
||
5. the concrete API capability or policy change that would reverse the
|
||
decision, or why the behavior must remain host-owned.
|
||
|
||
“Not supported”, “no API”, “renderer internals are unavailable”, and a list of
|
||
removed property names are not reasons. They describe absence. If the behavior
|
||
is acceptable and only a public destination is absent, that is `API-GAP`, not
|
||
`REFUSED`. If the behavior survives through another published mechanism, name
|
||
that destination and refuse only the old technique.
|
||
|
||
**A partial conversion is worse than a punt.** Two checks enforce this and both
|
||
fail the whole file:
|
||
|
||
- `retires-the-old-surface` — no `X.prototype.foo = ...` may remain. Converting
|
||
a function body while leaving the prototype assignment that reaches it moves
|
||
nothing; the old surface still cannot be deleted, which is the only reason
|
||
this programme exists.
|
||
- `no-unknown-api-members` — every member you introduce must exist in the
|
||
published API. Do not invent a plausible-sounding method. If the capability
|
||
you need is absent, that is `api-gap`, and naming it precisely is the most
|
||
useful thing you can do.
|
||
|
||
Both of these caught real conversions that every other check passed.
|
||
|
||
## Keep the diff small — it is the message
|
||
|
||
These diffs are read by the pack's author, often as a pull request. A patch that
|
||
touches ten lines says _we moved you off two deprecated calls_. A patch that
|
||
rewrites the file says _we rewrote your code_, and it will be rejected on sight
|
||
even when it is correct. Restructuring you did not need is not neutral.
|
||
|
||
**Change the registration. Leave everything else where it is.**
|
||
|
||
```js
|
||
// before
|
||
app.registerExtension({
|
||
name: 'x.ShowText',
|
||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||
if (nodeData.name === 'ShowText') {
|
||
function populate(text) {
|
||
/* 30 lines */
|
||
}
|
||
nodeType.prototype.onExecuted = function (message) {
|
||
populate.call(this, message.text)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
Two conversions of that, both correct:
|
||
|
||
```js
|
||
// ✗ restructured — populate hoisted, dedented, renamed, signature changed.
|
||
// Every line of a 30-line helper shows as changed.
|
||
function populateText(node, lines) {
|
||
/* 30 lines, reflowed */
|
||
}
|
||
comfy.defs.extend('ShowText', (b) => b.onExecuted(populateText))
|
||
|
||
// ✓ minimal — the helper stays exactly where and as it was.
|
||
comfy.defs.extend('ShowText', (b) => {
|
||
function populate(text) {
|
||
/* 30 lines, byte-identical */
|
||
}
|
||
b.onExecuted((node, result) => populate.call(node, result.text))
|
||
})
|
||
```
|
||
|
||
Both work. The second is the one an author merges.
|
||
|
||
Rules that follow from this:
|
||
|
||
- **Do not hoist, reorder or rename anything** the conversion does not require.
|
||
Keep helper names, parameter names and their order.
|
||
- **Keep helpers nested where they were nested.** Pulling one to module scope
|
||
changes every line of it.
|
||
- **Do not normalise style.** Quotes, semicolons, spacing and line breaks are
|
||
the author's, not yours.
|
||
- **Do not "improve" adjacent code.** A bug next to your change is not your
|
||
change.
|
||
|
||
### Indentation: match the original where you can, fix it where you must
|
||
|
||
The patch gets applied to the author's working tree, so a diff that minimises
|
||
its own size by leaving the body at its old depth ships them badly indented
|
||
code. That is a worse outcome than a larger diff.
|
||
|
||
The rule, in order:
|
||
|
||
1. **Prefer the original indentation.** If a line's nesting depth has not
|
||
actually changed, do not touch its leading whitespace.
|
||
2. **Re-indent when the nesting genuinely changed.** Removing a
|
||
`registerExtension` wrapper takes two levels off everything inside it, and
|
||
the result has to be correctly indented. Do it.
|
||
3. **Never re-indent anything whose nesting did not change.** That is the
|
||
churn worth eliminating, and it is entirely elective.
|
||
|
||
So the thing to avoid is not re-indentation, it is _gratuitous restructuring_ —
|
||
hoisting a helper to module scope, renaming its parameters, reflowing a function
|
||
the conversion never touched. Those change every line of code that did not need
|
||
to change. Correct indentation is not negotiable; unnecessary movement is.
|
||
|
||
Across the current database the unavoidable dedent accounts for 17–39% of added
|
||
lines. `run_checks` reports the proportion so you can see whether yours is in
|
||
that range or well past it.
|
||
|
||
## Recognise the intent, not the call
|
||
|
||
Most old-API code is a workaround for something missing. Port the call and you
|
||
carry the workaround across; recognise what it was _for_ and it usually
|
||
collapses. Check these before converting anything mechanically.
|
||
|
||
**If you are converting `api.addEventListener('b_preview')` or
|
||
`'b_preview_with_metadata'` or `'executing'`** — check whether the pack is
|
||
correlating frames to one node (a module-level `execId`, a
|
||
`displayNodeId === this.id` test, a `serverSupportsFeature` probe). That whole
|
||
apparatus answers "is this frame mine?". **Use `b.onPreview((node, frame) =>
|
||
…)`**, which answers it for you. Delete the global; it also mis-attributes
|
||
frames when two nodes preview at once.
|
||
|
||
**If you are converting `onDrawForeground` / `onDrawBackground` / anything
|
||
using `ctx`** — check what it actually draws. Rectangles, images, text and
|
||
lines are all a canvas can give you and all a DOM can. **Use
|
||
`node.widgets.canvas({ name, height, draw(ctx, [w, h]) })`** and keep the
|
||
drawing code as it is. It renders under both the old graph renderer and Nodes
|
||
2.0. Do not reach for the graph's shared context — that is the thing that ties
|
||
a pack to the old renderer.
|
||
|
||
**If you are converting hand-rolled hit testing** — bounding-box maths against
|
||
`node.pos`/`node.size`, pointer capture on `document`, hit tests against link
|
||
curves. Check whether it exists only because canvas has nothing to attach a
|
||
listener to. **Mount the control with `node.widgets.mount(...)` and use
|
||
ordinary DOM events**; most of the geometry disappears rather than being
|
||
ported.
|
||
|
||
**If you are converting `node.addDOMWidget(...)`** — **use
|
||
`node.widgets.mount({ name, render(container), destroy() })`**. Put the teardown
|
||
in `destroy`: a mounted element owns listeners, timers and observers that node
|
||
removal would otherwise leave running.
|
||
|
||
If that DOM widget's `getValue` / `setValue` held an editor document, give the
|
||
mount a `defaultValue` and synchronize through the `MountedValue` passed to
|
||
`render`. Set `serialize: true` and, for frontend-only state, `sendToPrompt:
|
||
false`; use the widget's `beforeSerialize` event when the live editor must be
|
||
sampled immediately before writing. Once the document is a real widget value,
|
||
normal workflow serialization also carries it through paste and duplicate — a
|
||
`clone()` override and side cache are not another requirement.
|
||
|
||
Count those cells per widget before consolidating an interface. Two legacy DOM
|
||
rows with workflow serialization enabled produce two positional
|
||
`widgets_values` entries even when both values are empty and neither reaches
|
||
the prompt. Replacing them with one larger mount changes the wire format; keep
|
||
two mounts, or prove from the widget flags that one row was never serialized.
|
||
|
||
If the old editor patched `ChangeTracker.undoRedo` to keep Ctrl+Z local, do not
|
||
port the patch. Focused `input`, `textarea`, and `contenteditable` elements own
|
||
their undo history, including while auto-queue-on-change is watching edits.
|
||
Mount the editor and keep its own undo/redo handlers on that element.
|
||
|
||
**If you are converting `node.addInput` / `removeInput` / `addOutput`** — the
|
||
pack is almost certainly growing slots as the last one fills (the "Multi"
|
||
combiner shape). **Use `node.inputs.add(name, type)` / `node.inputs.remove(ref)`**
|
||
and the matching `node.outputs` calls, driven from `b.onConnectionsChanged`.
|
||
|
||
**If you are converting `canvas.selected_nodes` / `selectedItems`** — the pack
|
||
wants the user's current selection, and is reaching into the canvas for it.
|
||
**Use `comfy.graph.selection()`**, which returns node handles.
|
||
|
||
**If you are writing to a node or widget handle** — every read and write is a
|
||
**method**, never a property: `setTitle`, `setColor`, `setBgColor`, `setMode`,
|
||
`setCollapsed`, `setProperty`, `getSize`/`setSize({ width, height })` on nodes;
|
||
`getValue`/`setValue`, `isHidden`/`setHidden`, `setOption` on widgets. Property
|
||
syntax compiles and silently does nothing. See the table in
|
||
`references/node-definitions.md`.
|
||
|
||
**If the pack registered a custom renderer for a backend-declared widget
|
||
type** — **use `comfy.defs.defineWidgetType(type, { render })`**, not an extra
|
||
mounted widget. The renderer receives the declared widget's value, so it keeps
|
||
the same positional `widgets_values` cell. Use `context.onNodeReady` when its
|
||
DOM needs the owning node; constructors run before a node has an id or graph.
|
||
|
||
**If you are converting `registerCustomNodes` / `extends LGraphNode` /
|
||
`isVirtualNode` / `applyToGraph`** — identify which of four intents the node
|
||
serves (annotation, wire, value, or acting on other nodes) and use **`comfy.defs.define`**
|
||
with `execution: 'frontend'` and, for wires and values, a pure `resolve`. See
|
||
`references/node-definitions.md` — nodes that act on others mutate neighbours through
|
||
handles in widget callbacks, never in `resolve`.
|
||
|
||
**If the pack deletes and recreates a node to repair it after definitions
|
||
change** — **use `comfy.graph.replace(node.id, node.type)`**. Same-type
|
||
replacement intentionally creates a fresh registered instance while retaining
|
||
the user's values, properties and compatible links in one undo step.
|
||
|
||
**If you are converting `onResize` / `computeSize` / a per-frame `setSize`** —
|
||
check whether the pack is enforcing a minimum, or growing to fit something it
|
||
mounted. **Use `node.setSizeConstraints({ minWidth, minHeight, maxWidth,
|
||
maxHeight, autoHeight })`** once, rather than re-asserting size on every frame.
|
||
`autoHeight` is usually the real intent.
|
||
|
||
**If you are converting `onSerialize` / `chainCallback(node, 'onSerialize')`** —
|
||
the pack is saving its own state into the node. **Use `b.onSerialize((node) =>
|
||
({ myKey: … }))`**; it comes back through `b.onConfigured`. Core fields —
|
||
`type`, `widgets_values`, `inputs`, `pos` and the rest — are ignored if you
|
||
return them, because changing those changes what the workflow means.
|
||
|
||
**If you are converting `onConnectInput` / `onConnectOutput`** — check what the
|
||
pack does with the return value. If it returns `false` to refuse a wire, **use
|
||
`b.onBeforeConnect((node, e) => …)`** and return `false` to refuse; `e` carries
|
||
`side`, `index`, `peerNodeId` and `peerType`. Any listener refusing is enough —
|
||
one pack cannot be overruled by another's silence. If it does not return
|
||
`false`, it is only observing, so **use `b.onConnectionsChanged`** instead: a
|
||
veto that never vetoes is a listener wearing the wrong hat.
|
||
|
||
**If you are converting `getExtraMenuOptions` or a `ContextMenu` the pack builds
|
||
itself** — check whether it is a menu _on a node_. If so, **use
|
||
`b.addMenuItem({ label, run(node) })`**; entries from every pack accumulate
|
||
rather than overwrite. A `ContextMenu` constructed to build a canvas-wide or
|
||
slot menu is not this, and is still a gap — punt it and name it.
|
||
|
||
**If you are converting a pack that writes its own name-keyed shape into
|
||
`widgets_values`** (a dict keyed by widget name, rather than the positional
|
||
array) — the pack is reaching for something the new model already gives it.
|
||
**Widget values are keyed by name at runtime**: `widgetValueStore` is keyed by
|
||
`graphId:nodeId:name` (`src/types/widgetId.ts`), with no index in the identity.
|
||
The positional array is only the legacy _serialized_ form, which is why
|
||
`widgets_values` is reserved.
|
||
|
||
So **delete the override, do not translate it.** Address widgets by name — that
|
||
is native — and let core serialize the positional array as it already does. The
|
||
pack's serialize hook largely disappears rather than being ported.
|
||
|
||
What you must keep is the _reading_ of old workflows. Core assigns positionally
|
||
and _then_ calls `onConfigure`, so `b.onConfigured` receives the saved node with
|
||
its legacy shape intact. Port the pack's rename maps, retired-widget handling
|
||
and positional-to-named fallbacks into that hook: those are the conversion, and
|
||
dropping them silently loses user data.
|
||
|
||
**If you are converting reads of `canvas.connecting_links`, `resizing_node` or
|
||
`node_widget`** — the pack is asking one question, not three: _is the editor
|
||
already mid-gesture?_ **Use `comfy.isInteracting()`** and stand down while it is
|
||
true. Do not reach for the individual fields; which gestures exist is the
|
||
editor's business and will change.
|
||
|
||
**If you are converting a pack that listens on `document` for pointer moves to
|
||
build an editing gesture** (drag one node onto another, shake to disconnect,
|
||
drop onto a link) — **use `comfy.onNodeMoved`**, which reports node movement
|
||
under both renderers from one subscription. Two cautions: it does not say
|
||
whether a _person_ moved the node, so guard your own writes against re-entry;
|
||
and for a gesture that commits on release, pair it with
|
||
`comfy.onNodeDragEnd(nodes => …)`, which reports every node the drag moved.
|
||
|
||
`onNodeDragEnd` is **Nodes 2.0 only** — the legacy canvas renderer publishes no
|
||
drag lifecycle, so it never fires there. Say so in your summary rather than
|
||
pretending the conversion is renderer-neutral.
|
||
|
||
**If you are converting `canvas.setDirty(...)` / `setDirtyCanvas(...)`** —
|
||
**delete it.** There is no published repaint request, deliberately. Handle
|
||
writes invalidate on their own, and a `widgets.canvas` surface has `redraw()`.
|
||
If something genuinely fails to repaint after a handle write, that is a bug in
|
||
the API — report it rather than working around it.
|
||
|
||
**If you are converting `addWidget('button', name, null, callback)` or any
|
||
widget whose callback is an action rather than a value change** — **use
|
||
`widget.on('activate', fn)`**. A button's value never moves, so `on('change')`
|
||
can never fire for one. Keep the widget itself (`widgets.add({ type: 'button',
|
||
name, value: null })`) — its `widgets_values` entry is positional, and dropping
|
||
it shifts every widget after it.
|
||
|
||
**If you are converting `registerExtension({ commands, keybindings })` or
|
||
`app.extensionManager.toast`** — **use `comfy.commands`**:
|
||
`register({ id, label, run, keybinding })` declares the command and binds its
|
||
key together, and `notify({ severity, summary, detail })` raises a toast. Ids
|
||
must be namespaced. The key is registered as a _default_, so a user's own
|
||
binding survives the pack re-registering on every load.
|
||
|
||
**If you are converting `api.apiURL(...)` or `api.addEventListener(...)`** —
|
||
**use `comfy.backend`**: `url('/view?…')` builds a route that honours how the
|
||
host is served, and `on(event, detail => …)` subscribes to any backend message,
|
||
including one your own Python side emits. For the built-in preview channel
|
||
prefer `b.onPreview`, which answers "is this frame for my node?" — `backend.on`
|
||
hands you the raw payload and leaves correlation to you.
|
||
|
||
**If the pack adds its own file input beside a backend COMBO** — inspect the
|
||
Python declaration before porting it. `image_upload`, `animated_image_upload`
|
||
and `video_upload` in that input's options tell the host to supply the chooser,
|
||
upload and preview. Delete the duplicate UI; adding another widget changes the
|
||
positional wire format while recreating behavior core already owns.
|
||
|
||
If a pack rewrites `graphToPrompt` only to name a cache or sidecar file, inspect
|
||
its Python before declaring the feature impossible. A pack-owned preview route
|
||
may already serve file inputs without a graph run; connected tensors can use
|
||
`queue.run({ nodes: [node] })`, then refresh the sidecar from `b.onExecuted` and
|
||
`execution_cached`. A hidden id declared as `UNIQUE_ID` is supplied by the host.
|
||
A hidden string default such as `"0"` is instead a pack-backend identity bug:
|
||
record the simultaneous-node limitation precisely, but do not mislabel the
|
||
published queue, execution and backend mechanisms as missing.
|
||
|
||
**If you are converting `app.ui.settings.getSettingValue` / `addSetting`, or a
|
||
`settings: [...]` array in `registerExtension`** — **use `comfy.settings`**:
|
||
`declare({ id, name, type, defaultValue })` once at load, then `get(id)` and
|
||
`await set(id, value)`. Ids must be namespaced (`MyPack.thing`) — one flat space
|
||
is shared with core and every other pack, and the id is where the value lives
|
||
permanently. Re-declaring does not reset a stored value, so declaring on every
|
||
load is correct.
|
||
|
||
**If the pack replaces the canvas background draw method only to show an
|
||
image** — **write `Comfy.Canvas.BackgroundImage` through `comfy.settings`**.
|
||
The host loads and redraws it under both renderers. Preserve and restore the
|
||
previous setting when the pack's temporary background mode stops.
|
||
|
||
**If you are converting a read of `LiteGraph.NODE_SLOT_HEIGHT`,
|
||
`NODE_TITLE_HEIGHT`, `ROUND_RADIUS` or `vueNodesMode`** — **use
|
||
the published answer to the operation, not another numeric renderer constant**.
|
||
`getBounds`, `getSlotPosition`, `getScreenRect`, `widgets.mount` and
|
||
`widgets.canvas` already account for layout and renderer choice. There is no
|
||
published `comfy.constants`; if behavior truly requires reproducing the
|
||
renderer geometry after those mechanisms are considered, name that specific
|
||
gap rather than inventing one.
|
||
|
||
**If you are converting `this._somethingPrivate = x` on a node** — handles hold
|
||
no arbitrary properties. **Keep a `Map` keyed by `node.id`** and clear the entry
|
||
in `b.onRemoved`. This is supported, not a workaround; the old property was
|
||
collected with the node and a Map is not.
|
||
|
||
**If you are converting `node.imgs = [img]` + `setDirtyCanvas`** — the pack is
|
||
showing an image on the node. **Use `widgets.canvas` and `drawImage` in
|
||
`draw`**, then `redraw()` when the image changes.
|
||
|
||
**If you are converting a captured-and-chained `widget.callback`** — check
|
||
whether it only wants to know the value changed. **Use `widget.on('change', (v,
|
||
old) => …)`**, which is additive: no other pack can drop your listener by
|
||
forgetting to call through.
|
||
|
||
**If you are converting `widget.serializeValue`** — classify its return. A
|
||
constant `undefined` becomes `serialize: false`; a synchronous substitute uses
|
||
`widget.on('beforeSerialize', event => event.setSerializedValue(value))`. If it
|
||
awaits derived state, an async `comfy.queue.guard` may commit that state before
|
||
the prompt is built, but guards time out after five seconds. Anything that can
|
||
legitimately take longer remains an API gap rather than a safe conversion.
|
||
Note the two flags are distinct: `options.serialize` gates the API prompt,
|
||
`widget.serialize` gates workflow persistence.
|
||
|
||
**If you are converting `widget.type = 'converted-widget'`** — the pack is
|
||
hiding a widget, not changing its kind. **Use `widget.setHidden(true)`.** The
|
||
`origType`/`origComputeSize`/`origSerializeValue` bookkeeping around it existed
|
||
only to undo the hack; it has no readers and goes away.
|
||
|
||
## Pattern references
|
||
|
||
Deep dives, loaded only when relevant. `SKILL.md` stays short on purpose; detail
|
||
lives here.
|
||
|
||
| Reference | Covers |
|
||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `references/nodegraph-101.md` | **Read first if you have not converted a pack before.** What a node graph is, the definition/class/instance distinction, the lifecycle, workflow vs prompt, and why packs patch prototypes at all. |
|
||
| `references/node-definitions.md` | `beforeRegisterNodeDef` + prototype patching — **1,265 packs, 47.4% of installs, the largest surface**. The selector is already written as the hook's guard clause. |
|
||
| `references/widgets.md` | Widget-array mutation and the converted-widget protocol — 286 packs / 21.6%, overlapping cohorts totalling more. Where naive conversions are most often silently wrong. |
|
||
| `references/draw-callbacks.md` | `onDraw*` — 420 packs, 32.2% of installs. Measured breakdown showing 47% never draw at all, a decision tree, and the canvas→CSS mapping. |
|
||
|
||
### Adding a capability
|
||
|
||
If a conversion is blocked and the fix is a **new API capability**, the addition
|
||
is a design decision, not a detail. Record it in `docs/node_api_WIP.md` §4f
|
||
alongside: what forced it, the alternative that was rejected, and what it would
|
||
cost to reverse. An addition made under conversion pressure with its rationale
|
||
only in a commit message is one nobody can argue with later.
|
||
|
||
### Adding a pattern
|
||
|
||
Add a reference when a pattern is (a) seen in real pack code, not anticipated,
|
||
and (b) large or subtle enough that the mapping table row is insufficient.
|
||
|
||
Each reference should carry:
|
||
|
||
1. **How common it is**, measured — not estimated. Counts come from the corpus
|
||
at `~/comfy/nodes-compat-study/`, and should be labelled grep-derived where
|
||
they are.
|
||
2. **What the code is actually doing**, which is often not what the API name
|
||
suggests. Classify before mapping.
|
||
3. **Real before/after**, quoted from a named pack and file.
|
||
4. **The trap** — the way a plausible conversion silently goes wrong.
|
||
|
||
If a pattern turns out to be mechanically safe, add it to
|
||
`conversion/rules.ts` instead: a rule with golden cases beats prose, because CI
|
||
runs it. Prose is for the judgement calls.
|
||
|
||
## Where things live
|
||
|
||
| | |
|
||
| ----------------------------------- | --------------------------------------------------------- |
|
||
| Published API spec | `docs/node_api_WIP.md` |
|
||
| Published API code | `src/platform/nodeApi/` |
|
||
| Rule catalog (per-pattern guidance) | `src/workbench/extensions/magicPatch/conversion/rules.ts` |
|
||
| Verdict grading | `src/workbench/extensions/magicPatch/verify/verdict.ts` |
|
||
| Programme design | `docs/magic_patch_WIP.md` |
|
||
|
||
The rule catalog's `guidance` field is injected into the agent prompt for
|
||
**only the rules that matched**, so this skill covers method and the catalog
|
||
covers specifics. Keep it that way — duplicating pattern detail here means it
|
||
will drift.
|
||
|
||
## Checklist
|
||
|
||
- [ ] Traced every `link`/`links`/`widgets` reference to live-vs-serialized
|
||
- [ ] No shim, no compatibility wrapper, no reintroduced old API
|
||
- [ ] Link ids preserved where links were moved
|
||
- [ ] Equivalence claim stated and passing on both sources
|
||
- [ ] Fix claim red on the original, green on the converted source
|
||
- [ ] `graphToPrompt` and serialized workflow byte-identical
|
||
- [ ] Anything uncertain escalated rather than guessed
|