379 lines
21 KiB
Markdown
379 lines
21 KiB
Markdown
# Contributing
|
||
|
||
Bug reports, workflow ideas and pull requests are welcome.
|
||
|
||
## Reporting a problem
|
||
|
||
The most useful report names:
|
||
|
||
- the ComfyUI and frontend versions (Settings → About),
|
||
- the pack version from the console banner (`AusBoss vX.Y.Z | N nodes loaded`),
|
||
- the node, what you expected, and what happened instead,
|
||
- the smallest workflow that shows it, plus a screenshot for anything visual.
|
||
|
||
Security problems go through [SECURITY.md](SECURITY.md) instead of a public
|
||
issue.
|
||
|
||
## Pull requests
|
||
|
||
- Read the contributor guide below first, and a new public node
|
||
follows [docs/adding_a_node.md](docs/adding_a_node.md). A new node is a
|
||
conversation before it is a pull request.
|
||
- Mapping keys, input names and order, and output order are a permanent API:
|
||
saved workflows depend on them. Append optional inputs and outputs instead
|
||
of renaming or reordering. `tests/test_node_api.py` checks this against a
|
||
snapshot; its docstring shows how to refresh the snapshot after a
|
||
compatible change.
|
||
- Leave `version` in `pyproject.toml` alone. A version change that reaches
|
||
`main` publishes to the Comfy Registry.
|
||
- Run the offline checks. None of them needs a GPU; the Python tests need
|
||
ComfyUI's Python environment.
|
||
|
||
```bash
|
||
python scripts/validate_nodes.py
|
||
python scripts/release_preflight.py
|
||
python scripts/run_python_tests.py
|
||
node --test tests/*.test.mjs
|
||
```
|
||
|
||
- Frontend changes are checked on a real canvas as well as in tests.
|
||
[docs/live_testing.md](docs/live_testing.md) describes the headless browser
|
||
harness and what to screenshot.
|
||
- A fix PR says what was wrong, adds a test that fails before the fix and
|
||
passes after it, and shows before/after results.
|
||
|
||
## Contributor guide
|
||
|
||
How the repo is laid out, the conventions every node follows, and the checks
|
||
to run. A suite of polished ComfyUI custom nodes by ausboss. Public nodes must
|
||
solve a repeated workflow need, keep a compact graph footprint, and pass
|
||
backend plus browser acceptance before release.
|
||
|
||
### READ FIRST: write for the people who use the nodes
|
||
|
||
Everything a user reads starts with the plainest version. That covers node
|
||
descriptions, tooltips, the `?` help pages (`js/docs/*.md`), the README,
|
||
example workflow notes, report and error text, and the CHANGELOG. Get more
|
||
technical later in the text, or in its own section.
|
||
|
||
- Open with what it does and when to use it, in everyday words and short
|
||
sentences. Someone new to ComfyUI should get it on the first read.
|
||
- A tooltip is one or two short sentences: what this is, and what to pick.
|
||
- Measurements, test results, edge cases and how it works go further down,
|
||
under a heading such as "Technical details".
|
||
- Keep jargon out of the opening (resample, warp, frame, affine,
|
||
estimator, canvas space). If a term is needed, say what it means the
|
||
first time.
|
||
- Put a number up top only when it helps someone decide what to do.
|
||
|
||
If a sentence needs a second read, rewrite it.
|
||
|
||
### Which nodes ship
|
||
|
||
A node ships only when its mapping key is listed in `PUBLIC_NODE_IDS` in
|
||
`scripts/validate_nodes.py`; the validator fails on any registered key that
|
||
is not listed there.
|
||
|
||
### Hard rules
|
||
|
||
- Never modify `LICENSE`.
|
||
- Never bump `version` in `pyproject.toml` — a version bump that lands on
|
||
main **publishes to the Comfy Registry automatically** (see Releasing).
|
||
- Keep diffs minimal: touch only the lines the task needs.
|
||
|
||
### Third-party independence
|
||
|
||
- Never copy third-party code, assets, fonts, icons, CSS, or documentation.
|
||
- Review ecosystem overlap before accepting a public node. Generic overlap is
|
||
fine, but implementation, naming, interaction design, and documentation must
|
||
be this repository's own work.
|
||
|
||
### Architecture
|
||
|
||
```text
|
||
__init__.py # NODE_MODULES list → importlib merge of all mappings.
|
||
# Fail-soft: a broken module logs and is skipped, the rest load.
|
||
nodes/
|
||
node_<name>.py # exactly one node (or one tight family) per file;
|
||
# exports NODE_CLASS_MAPPINGS + NODE_DISPLAY_NAME_MAPPINGS
|
||
_<topic>_helpers.py # shared backend logic, underscore prefix = not a node
|
||
js/
|
||
<name>/index.js # frontend entry per node or pack-wide feature, e.g.
|
||
# appearance/ (.js files auto-load)
|
||
shared/*.mjs # import-only shared modules (.mjs files do NOT auto-load)
|
||
docs/ # developer docs
|
||
scripts/ # offline checks in stdlib Python. validate_nodes.py is
|
||
# the entry point; registry_contract.py holds the rules
|
||
# that keep nodes visible to registry scanners;
|
||
# registry_status.py reads the Registry API; dev/ is the
|
||
# Node.js canvas harness (docs/live_testing.md).
|
||
example_workflows/ # example workflows (regular workflow JSON, not API JSON)
|
||
```
|
||
|
||
### Conventions
|
||
|
||
- Public mapping keys use `AUSBOSS_NODES_<Purpose>`. The mapping key is the
|
||
workflow-compatibility contract and must never be renamed after release.
|
||
- Inputs and outputs only grow after a release: append an input as
|
||
optional, append an output, and never rename, reorder, remove or newly
|
||
require one. Saved workflows keep widget values by position and links by
|
||
slot, and an API prompt must carry every required input.
|
||
`tests/test_node_api.py` holds the pack to `tests/fixtures/node_api.json`;
|
||
refresh that snapshot with the test's `--update` after a compatible change
|
||
or a new node.
|
||
- Appending an input to a node with a card or panel takes one more step.
|
||
Workflows saved by earlier releases end that node's values with an empty
|
||
value for each card and panel, and values come back by position, so the
|
||
new input opens holding that empty value. Give a widget input a
|
||
`resetUnknown` fallback in its card (`js/widget_cards/index.js`), or a
|
||
`RESET_UNKNOWN` beside `NODE_CLASS` in the node's own `js/<name>/index.js`
|
||
if it has no card (as LoRA Loader does). The fallback equals the node's
|
||
default, which is what an API prompt without the input runs with. Then
|
||
list the input in `tests/saved_widget_values.test.mjs`. Cards and
|
||
panels themselves are never saved: set `widget.serialize = false` right
|
||
after `addDOMWidget` (`options.serialize` only keeps a widget out of the
|
||
prompt).
|
||
- Write those keys as **string literals** inside `NODE_CLASS_MAPPINGS` and
|
||
`NODE_DISPLAY_NAME_MAPPINGS` — never a `NODE_ID` variable. Registry scanners
|
||
(ComfyUI-Manager) AST-parse the source without importing it, so a variable
|
||
key makes every node invisible and "install missing custom nodes" stops
|
||
offering the pack. `scripts/validate_nodes.py` enforces this.
|
||
- Assign each mapping **once**, at module level, to a non-empty dict literal,
|
||
and never mention the name again — no `update()`, no `del`, no
|
||
`alias = NODE_CLASS_MAPPINGS`. A scanner reads that one literal and stops,
|
||
so anything done to the mapping afterwards is invisible to it. Both
|
||
mappings must carry exactly the same keys.
|
||
- Display name: `<Name> 🆎` — the emoji is the pack signature. Typing
|
||
"ausboss" still surfaces every node through the `🆎 AusBoss/<Group>`
|
||
category, the `AUSBOSS_NODES_` id prefix, and the "ausboss" entry every
|
||
node keeps in `SEARCH_ALIASES`.
|
||
- Category: `🆎 AusBoss/<Group>`. The emoji is safe here — categories reach
|
||
the frontend as JSON and are never printed to the console at import time.
|
||
- Every node gets `DESCRIPTION`, input `tooltip`s, and `OUTPUT_TOOLTIPS`.
|
||
- IMAGE tensors are BHWC float batches; MASK is BHW. Return tuples always,
|
||
even for one output: `(value,)`.
|
||
- Console output at import time must stay ASCII — ComfyUI on Windows often
|
||
runs a cp1252 console, and a UnicodeEncodeError there kills the whole pack.
|
||
- Widget values and route parameters are attacker-controlled: ComfyUI's
|
||
`/prompt` and the pack's routes need no login. Media reads and writes stay
|
||
inside ComfyUI's input, output and temp folders - no opt-in switches, no
|
||
"any folder" settings, no folder pickers. A file is picked from a list and
|
||
named below one of those folders the way ComfyUI names it
|
||
(`clip.mp4 [output]`); no input opens a whole path as given.
|
||
A route answers with data it made (JSON, a small picture), never a file by
|
||
its path (`web.FileResponse`), and its error text goes through
|
||
`safe_route_error` (`nodes/_route_errors.py`). Besides those the pack only reads
|
||
its registered model folders (the one write there is LoRA Loader's Civitai
|
||
button: a `.civitai.info` file and a `.preview.jpg` picture, saved beside a
|
||
LoRA, never replacing a picture) and keeps its own
|
||
settings files in ComfyUI's user folder (`user/ausboss/`). Nothing is handed to a
|
||
subprocess. The Registry bans versions for exactly this.
|
||
- The pack's server code makes no network requests: no HTTP or socket client
|
||
in shipped Python (`release_preflight.py` fails on one). The one outbound
|
||
call is LoRA Loader's click-only Civitai lookup, made by the browser to a
|
||
fixed civitai.com address with a plain SHA256, plus the LoRA's picture
|
||
from `image.civitai.com` on the same click (`js/lora_loader/index.js`,
|
||
`js/shared/civitai_lookup.mjs`). `release_preflight.py` allows a raw
|
||
`fetch(` only there and in `js/cache_guard`, which only talks to the pack's
|
||
own server. Graph links are made with
|
||
`linkSlots` from `js/shared/graph_links.mjs`, never the index-based
|
||
`node.connect(...)`, which registry scans read as a network socket.
|
||
SECURITY.md states the model for users.
|
||
- No new pip dependencies without an explicit decision; if truly optional,
|
||
use `[project.optional-dependencies]` and fail soft at runtime.
|
||
- Frontend files import each other only with plain string literals
|
||
(`from "../shared/x.mjs"`, `import("../shared/x.mjs")`). The server adds a
|
||
version tag to those paths (`nodes/_frontend_cache.py`) so an update never
|
||
meets old copies from the browser's cache. A path built in code
|
||
(`new URL(...)`, `"../shared/" + name`) escapes the tag and loads a second,
|
||
stale copy; `tests/test_frontend_cache.py` fails on one.
|
||
- Frontend JS never assigns prototype callbacks directly — use
|
||
`chainCallback` from `js/shared/index.mjs`, or `chainHandler` where the
|
||
return value matters (a truthy `onMouseDown` result is what stops a node
|
||
drag). Messages to the user go through `showToast`, never `alert()`.
|
||
- The mouse wheel over a node zooms the graph, on every part of its face.
|
||
`js/canvas_passthrough/` hands the wheel on from the pack's panels, and
|
||
keeps it only for a box that can still scroll or a field being typed in,
|
||
so a panel adds no wheel listener of its own
|
||
(`tests/canvas_passthrough.test.mjs` lists the few that exist). After a
|
||
change to a node face, `scripts/dev/wheel_sweep.mjs` turns the wheel at
|
||
every point of every node and must find no dead spot, in both renderers.
|
||
- Every INT and FLOAT a public node exposes reaches the user as a scrub
|
||
control, Adobe-style: drag the value to scrub, click to type, chevron
|
||
arrows step, Shift is always the fine step. Use `makeScrubInput` from
|
||
`js/shared/scrub_input.mjs` — never a bare `<input type=number>`, and
|
||
never a classic canvas number widget on a finished node face. Units
|
||
(`px`, `MP`, `×`) ride inside the box in a fixed-width slot that every
|
||
single-field row reserves, so the numbers down a card share one centre
|
||
line. Known, deliberate exceptions: the Seed card's seed (typed) and Load
|
||
Video's trim timecodes (typed).
|
||
- **Node faces are widget cards.** A node whose face would be classic
|
||
LiteGraph widgets (full-width rows with an arrow at each end) gets an
|
||
entry in `js/widget_cards/index.js` instead: `mountWidgetCard` from
|
||
`js/shared/widget_card.mjs` hides the standard widgets and mirrors them
|
||
in one compact DOM card — numbers → scrub, ≤ 4 short choices → segmented
|
||
pill (else a select), booleans → an off | on pill as wide as the other
|
||
controls (never a small switch inside a card), strings → text field,
|
||
multiline → `kind: "textarea"` (`grow: true` takes the node's spare
|
||
height), hex colors → swatch. Rows can depend on other values (`when`)
|
||
and fold behind a disclosure (`group`). The widgets underneath stay the
|
||
single source of truth (save/load, undo, API, links).
|
||
- **One linkable widget per row.** The frontend (≥ 1.10.4, "Widget Input
|
||
Socket" RFC #9) gives every widget an input socket drawn at the widget's
|
||
own row, only while a link is dragged, hovered or connected; "convert to
|
||
input" no longer exists. The card lends each hidden widget its row's
|
||
position, so two widgets on one row would stack two sockets on one pixel
|
||
and nobody could aim. A `pair` row is therefore only allowed with
|
||
`top: true`, which lifts its sockets into the node's slot column under
|
||
the real inputs (Image Resize width/height); everything else is one
|
||
widget per row. A value that is meant to be wired rather than typed is a
|
||
backend `forceInput: True` socket (Math Expression `a`/`b`/`c`), and a
|
||
multi-type string such as `"FLOAT,INT"` accepts either kind of link.
|
||
- Preview-carrying nodes (Select Frame, Mask Refine, Mask by Name, LaMa
|
||
Inpaint, Save Image) share
|
||
`js/input_preview/`: a thin bar (node tools left, a small `preview`
|
||
switch right) above the picture, the picture gone and the node shorter
|
||
when the switch is off, backed by an optional `preview` BOOLEAN input
|
||
that also skips the temp file. A new node with a result picture reuses
|
||
it rather than growing its own.
|
||
- A DOM panel that shows a stage/preview claims the node's free height via
|
||
`fillNodeHeight` from `js/shared/panel_layout.mjs` — never a hand-rolled
|
||
`computeSize`, which pins the panel and leaves dead space when the node is
|
||
dragged taller. `tests/panel_guards.test.mjs` enforces this pack-wide: a
|
||
new panel entry must be added to its `mustGrow` set (or `fixedByDesign`
|
||
for genuinely constant-height rows), so the choice is always explicit.
|
||
A `fixedByDesign` card also calls `pinVuePanelHeight`: its own
|
||
`computeSize` only pins it in the classic renderer, and Nodes 2.0 would
|
||
stretch it with the node and squeeze a picture under it. Run Timer does
|
||
not: its readout fills the node on purpose.
|
||
- **A picture or clip in a stage sits out of flow.** Nodes 2.0 sizes a node
|
||
by its content, so an in-flow portrait clip drawn at full width held Load
|
||
Video at 965 px where classic goes to 584 px. Position the media
|
||
`absolute` in its stage, and the stage takes its size from the node
|
||
alone. A picture is centred at its own size (`js/input_preview/`); a
|
||
player fills the stage so its controls stay along the bottom edge
|
||
(`js/shared/video_ui.mjs`). Text that is fitted to its box follows the
|
||
same rule: the Callout's text is `absolute` in its card
|
||
(`js/callout/index.js`), or the text sizes the card it is fitted to.
|
||
`tests/panel_guards.test.mjs` holds all three.
|
||
- **A panel's declared minimum width is its node's real floor.** Pass
|
||
`exactMinWidth: true` to `fillNodeHeight`; a constant-height card that
|
||
keeps its own `computeSize` calls `holdNodeMinWidth` instead. Left alone,
|
||
the frontend adds 104 px to a growing panel's number and ignores a pinned
|
||
card's number altogether (the node falls to its 210 px default), so a
|
||
declared 320 meant 424 for one and 210 for the other. Prove each face on
|
||
a real canvas at that width in both renderers; a panel that clips there
|
||
gets a larger minimum, not a broken face. Nodes 2.0 never goes under
|
||
225 px whatever a panel declares.
|
||
- **A growing panel's declared minimum height holds in Nodes 2.0 too.**
|
||
Nodes 2.0 lays a node out by its content, not by the floor a widget
|
||
declares, so a panel whose content has no height of its own was dragged
|
||
flat there. `fillNodeHeight` also writes the floor to the panel's
|
||
element as a CSS `min-height`, less `WIDGET_FRAME`, while the panel sits
|
||
inside a Nodes 2.0 node. So declare `minHeight` as what the element needs
|
||
plus `WIDGET_FRAME`, and never assign a growing panel's
|
||
`computeLayoutSize` by hand. Prove the floor with a real corner drag up
|
||
in both renderers.
|
||
- Frontend settings use `AusBoss.<Area>.<Name>` ids with
|
||
`category: ["🆎 AusBoss", "<Area>", "<Leaf>"]` and a distinct leaf per
|
||
setting. Node color schemes live in `js/shared/appearance.mjs`.
|
||
|
||
### Adding a node
|
||
|
||
Follow `docs/adding_a_node.md`. Short version: create `nodes/node_<name>.py`
|
||
modelled on a small existing node such as `nodes/node_image_size.py`, add
|
||
`"node_<name>"` to `NODE_MODULES` in `__init__.py` and the key to
|
||
`PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`, give it a help page at
|
||
`js/docs/<KEY>.md`, optionally add `js/<name>/index.js`, then validate.
|
||
|
||
### Validation
|
||
|
||
Run the offline checks before a pull request:
|
||
|
||
```bash
|
||
python scripts/validate_nodes.py
|
||
python scripts/release_preflight.py
|
||
python scripts/run_python_tests.py # with ComfyUI's Python
|
||
node --test tests/*.test.mjs
|
||
```
|
||
|
||
Then restart ComfyUI fully (a changed `INPUT_TYPES` is only served after a
|
||
restart), watch the AusBoss banner for failed modules, confirm the node
|
||
appears in `GET http://127.0.0.1:8188/object_info`, queue a tiny API graph,
|
||
and load its example workflow. After JS changes, hard-refresh the browser
|
||
tab (Ctrl+Shift+R) — the frontend caches `.mjs` modules aggressively.
|
||
|
||
Frontend work is proven on a real canvas, not by reading code: the
|
||
headless-Chrome harness in `scripts/dev/` (recipe, gotchas and the
|
||
screenshot convention in `docs/live_testing.md`) creates nodes, drives real
|
||
mouse drags, and screenshots the result. A link-drop test that only checks
|
||
which input got the link is not enough — look at the picture and ask whether
|
||
a person could have aimed there.
|
||
|
||
A ComfyUI update can break a node without a line of the pack changing. So
|
||
the pack is run against the newest ComfyUI every night
|
||
(`.github/workflows/comfyui_compat.yml`): its newest release and its
|
||
development head, on the CPU, with no model files. Three checks, which also
|
||
run by hand against any ComfyUI:
|
||
|
||
```bash
|
||
AUSBOSS_COMFY_ROOT=<ComfyUI> python tests/test_comfy_core_contract.py
|
||
python scripts/compat_boot.py --url http://127.0.0.1:8188
|
||
node scripts/dev/face_smoke.mjs <chrome port> # and again with --vue
|
||
```
|
||
|
||
- `test_comfy_core_contract.py` reads the pack's own source for everything
|
||
it takes from ComfyUI and looks each thing up in the real one. It finds
|
||
imports by itself. A call to one of ComfyUI's own nodes by name
|
||
(`SAM3_Detect`), or an internal read with a fallback, goes in the tables at
|
||
the top of that file; the test fails on a node call that is not listed.
|
||
- `compat_boot.py` asks a running ComfyUI which nodes it offers: every public
|
||
node of the pack, and every node and required input the examples use.
|
||
- `face_smoke.mjs` puts every node on a graph and fails when a face does not
|
||
come up or something throws.
|
||
|
||
A failure on the release opens an issue labelled `comfyui-update`. What these
|
||
cannot see is anything that needs a model: run the real thing on a GPU
|
||
before a release that follows a ComfyUI update.
|
||
|
||
### Releasing
|
||
|
||
A new `version` in `pyproject.toml` that lands on main publishes to the Comfy
|
||
Registry through the publish workflow (`.github/workflows/publish_action.yml`).
|
||
Contributors never change `version`.
|
||
|
||
The Registry shows notes with each version. They are that version's section
|
||
of `CHANGELOG.md`, one line per entry: its bold opening, and for a new node
|
||
the sentence that says what it does (`scripts/changelog_notes.py`). So open
|
||
every entry with a bold line that makes sense on its own.
|
||
|
||
### Example workflows
|
||
|
||
Examples are what most people run first, so each one must work for a
|
||
stranger with only the files its Workflow Note lists.
|
||
|
||
- Every model loader selects the official file name, the basename of its
|
||
download URL, carries that file in `properties.models`
|
||
(`name`, `url`, `directory`), and the Note's model row names the same
|
||
file. People who keep models in subfolders pick their copy.
|
||
- Media loaders (Load Image, Load Image + Pad, Load Video, Image Crop +
|
||
Rotate + Pad and the video nodes) are saved blank. A file name is one the
|
||
stranger doesn't have, and ComfyUI flags that node red when the workflow
|
||
loads. The Workflow Note tells people to load their own picture; the
|
||
sample files stay in `example_workflows/inputs/` for anyone who wants to
|
||
try one.
|
||
- Save nodes use a filename prefix without a folder. Seeds are fixed.
|
||
- Numbered groups hold every node, nothing overlaps (title bars included),
|
||
the saved zoom is at least 0.6 so widget text draws, the workflow `id` is
|
||
a real uuid4, and the `.jpg` beside the `.json` shows a real output.
|
||
- The frontend saves widget values twice, by position and by name
|
||
(`widgets_values_named`), and can restore by name; both copies must agree.
|
||
- `scripts/workflow_contract.py` checks links, groups, overlaps, the note,
|
||
the id, the zoom, the named copies, the download info and the save
|
||
prefixes. Whether the note tells the truth about the graph is review.
|
||
- Text-to-image examples have no source to describe. Edit and inpaint
|
||
examples keep an explicit change instruction: a description of the old
|
||
scene is not an edit. MiniMax H3 and Klein caption their sources with
|
||
Qwen3-VL 8B because their own text encoders returned only punctuation
|
||
when asked to describe an image.
|