From 1fcc4576eb0efe0b34e1f339cc27665e6c3ae136 Mon Sep 17 00:00:00 2001
From: pbanddev <202373785+PBandDev@users.noreply.github.com>
Date: Sun, 4 Jan 2026 15:10:44 -0600
Subject: [PATCH] Initial commit
---
.gitattributes | 3 +
.github/workflows/publish_action.yaml | 87 +++
.gitignore | 17 +
CLAUDE.md | 72 ++
LICENSE | 661 +++++++++++++++++++
README.md | 25 +
__init__.py | 5 +
assets/cog.svg | 1 +
package.json | 33 +
pnpm-lock.yaml | 819 +++++++++++++++++++++++
pnpm-workspace.yaml | 2 +
pyproject.toml | 41 ++
src/index.ts | 106 +++
src/layout/bin-pack.ts | 225 +++++++
src/layout/graph-builder.ts | 502 ++++++++++++++
src/layout/groups.ts | 147 +++++
src/layout/index.ts | 103 +++
src/layout/layer-assign.ts | 102 +++
src/layout/node-classifier.ts | 79 +++
src/layout/ordering.ts | 195 ++++++
src/layout/positioning.ts | 901 ++++++++++++++++++++++++++
src/layout/reroute-collapse.ts | 277 ++++++++
src/layout/types.ts | 263 ++++++++
tsconfig.json | 25 +
vite.config.ts | 33 +
25 files changed, 4724 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .github/workflows/publish_action.yaml
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 __init__.py
create mode 100644 assets/cog.svg
create mode 100644 package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 pnpm-workspace.yaml
create mode 100644 pyproject.toml
create mode 100644 src/index.ts
create mode 100644 src/layout/bin-pack.ts
create mode 100644 src/layout/graph-builder.ts
create mode 100644 src/layout/groups.ts
create mode 100644 src/layout/index.ts
create mode 100644 src/layout/layer-assign.ts
create mode 100644 src/layout/node-classifier.ts
create mode 100644 src/layout/ordering.ts
create mode 100644 src/layout/positioning.ts
create mode 100644 src/layout/reroute-collapse.ts
create mode 100644 src/layout/types.ts
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5dc46e6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.{cmd,[cC][mM][dD]} text eol=crlf
+*.{bat,[bB][aA][tT]} text eol=crlf
\ No newline at end of file
diff --git a/.github/workflows/publish_action.yaml b/.github/workflows/publish_action.yaml
new file mode 100644
index 0000000..132ed2f
--- /dev/null
+++ b/.github/workflows/publish_action.yaml
@@ -0,0 +1,87 @@
+name: Publish to Comfy registry
+
+on:
+ workflow_dispatch:
+ inputs:
+ bump_type:
+ description: "Version bump type"
+ required: true
+ type: choice
+ options:
+ - patch
+ - minor
+ - major
+ default: patch
+
+jobs:
+ bump-version:
+ name: Bump version and create tag
+ runs-on: ubuntu-latest
+ if: github.actor == github.repository_owner
+ permissions:
+ contents: write
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Bump version
+ id: bump
+ uses: callowayproject/bump-my-version@master
+ env:
+ BUMPVERSION_TAG: "true"
+ with:
+ args: ${{ github.event.inputs.bump_type }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create GitHub Release
+ if: steps.bump.outputs.bumped == 'true'
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: v${{ steps.bump.outputs.current-version }}
+ name: Release v${{ steps.bump.outputs.current-version }}
+ body: |
+ ## Version ${{ steps.bump.outputs.current-version }}
+
+ Bumped from version ${{ steps.bump.outputs.previous-version }} to ${{ steps.bump.outputs.current-version }}
+
+ This release was automatically created by the version bump workflow.
+ draft: false
+ prerelease: false
+ generate_release_notes: true
+ publish-node:
+ name: Publish Custom Node to registry
+ runs-on: ubuntu-latest
+ needs: bump-version
+ if: github.actor == github.repository_owner
+ strategy:
+ matrix:
+ node-version: [20]
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: "pnpm"
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build custom node
+ run: pnpm build
+
+ - name: Publish Custom Node
+ uses: Comfy-Org/publish-node-action@main
+ with:
+ personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+ skip_checkout: "true"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0a92364
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+node_modules/
+__pycache__/
+dist/
+.DS_Store
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+node.zip
+.vscode/
+.claude/
+.changeset/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b89b2a1
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,72 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+ComfyUI custom node extension that organizes nodes. Frontend-only extension (no Python backend nodes) that uses TypeScript/Vite for the UI.
+
+## Commands
+
+```bash
+pnpm install # install deps
+pnpm dev # dev server
+pnpm build # typecheck + build to dist/
+pnpm watch # build with watch mode
+pnpm typecheck # tsc --noEmit
+```
+
+Version bumping: `uv run bump-my-version bump patch|minor|major`
+
+## Architecture
+
+**Dual-stack extension**: Python entrypoint + TypeScript frontend
+
+- `__init__.py` - ComfyUI entrypoint, points `WEB_DIRECTORY` to `./dist`
+- `src/index.ts` - Extension entry, registers via `app.registerExtension()`
+- `dist/` - Built JS output, loaded by ComfyUI frontend
+
+**Build system**: Vite with custom plugin that injects `import { app } from "/scripts/app.js"` at build time. ComfyUI scripts are external (not bundled).
+
+**Types**: `@comfyorg/comfyui-frontend-types` provides ComfyApp interface. Global `app` declared for runtime access.
+
+## ComfyUI Extension Pattern
+
+Extensions register hooks via `app.registerExtension({ name, setup?, ... })`. The `app` object provides access to:
+- Graph/canvas manipulation
+- Node lifecycle hooks
+- UI customization APIs
+
+Use docs-mcp server for ComfyUI API reference.
+
+## Layout Algorithm
+
+**Hybrid Sugiyama + Bin Packing** in `src/layout/`:
+
+1. `node-classifier.ts` - Classify nodes (connected/disconnected/reroutes)
+2. `reroute-collapse.ts` - Collapse reroute chains into virtual edges
+3. `graph-builder.ts` - Build DAG from connected nodes only
+4. `layer-assign.ts` - Longest-path layer assignment
+5. `ordering.ts` - Size-aware barycenter crossing minimization
+6. `bin-pack.ts` - FFDH algorithm for multi-column packing
+7. `positioning.ts` - Assign coordinates, place disconnected nodes, restore reroutes
+8. `groups.ts` - Resize groups to fit members
+
+**Configuration** in `types.ts`:
+- `maxColumns`: 0=auto, 1=vertical stack, 2+=fixed columns
+- `collapseReroutes`: true to collapse reroute chains
+- `disconnectedGap`: gap between disconnected zone and DAG
+
+**Key design decisions**:
+- `compactVertically` excludes groups from max height (prevents Y explosion)
+- `positionGroupContents` uses translation (preserves relative positions)
+- Group height uses bounding box (not sum of heights)
+- `resolveGroupOverlaps` detects and shifts overlapping groups using minimal direction (up/down/right)
+- `assignNodeYPositions` tracks reserved Y regions to prevent node/group overlap within layers
+
+## CI/CD
+
+`.github/workflows/publish_action.yaml` - Manual dispatch workflow that:
+1. Bumps version via bump-my-version
+2. Creates GitHub release
+3. Builds and publishes to Comfy registry
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..162676c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+ Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+
+The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+The Corresponding Source for a work in source code form is that
+same work.
+
+2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e87d618
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# ComfyUI Node Organizer
+
+Automatically organizes nodes in your workflow using a compact hierarchical layout.
+
+## Features
+
+- **Execution Order Layout**: Nodes arranged left-to-right following data flow
+- **Compact Bin Packing**: Reduces vertical height by packing nodes into rows
+- **Disconnected Node Handling**: Annotations, bookmarks placed in separate left margin
+- **Reroute Optimization**: Reroute chains collapsed and restored along edges
+- **Group Support**: Nested groups with proper hierarchy
+- **Subgraph Support**: Works inside subgraphs with I/O node handling
+
+## Usage
+
+Right-click on the canvas and select **Organize Workflow**.
+
+---
+
+## Installation
+
+1. Open **ComfyUI**
+2. Go to **Manager > Custom Node Manager**
+3. Search for `Node Organizer`
+4. Click **Install**
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..1241be8
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,5 @@
+WEB_DIRECTORY = "./dist"
+NODE_CLASS_MAPPINGS = {}
+NODE_DISPLAY_NAME_MAPPINGS = {}
+
+__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
diff --git a/assets/cog.svg b/assets/cog.svg
new file mode 100644
index 0000000..e28dad8
--- /dev/null
+++ b/assets/cog.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ea19cc9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "comfyui-node-organizer",
+ "version": "1.2.0",
+ "description": "ComfyUI extension to organize nodes",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "watch": "tsc && vite build --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "keywords": [
+ "comfyui",
+ "custom node",
+ "custom",
+ "node",
+ "extension",
+ "organizer",
+ "node",
+ "extension"
+ ],
+ "author": "PBandDev",
+ "license": "AGPL-3.0",
+ "packageManager": "pnpm@10.17.1",
+ "dependencies": {
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12"
+ },
+ "devDependencies": {
+ "@comfyorg/comfyui-frontend-types": "^1.32.1",
+ "@types/node": "^24.10.0"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..c2aa98a
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,819 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.1.12
+ version: 7.3.0(@types/node@24.10.4)
+ devDependencies:
+ '@comfyorg/comfyui-frontend-types':
+ specifier: ^1.32.1
+ version: 1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)
+ '@types/node':
+ specifier: ^24.10.0
+ version: 24.10.4
+
+packages:
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.5':
+ resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.28.5':
+ resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
+ engines: {node: '>=6.9.0'}
+
+ '@comfyorg/comfyui-frontend-types@1.36.3':
+ resolution: {integrity: sha512-eWYkcEtFCUpv6gbsvFHLILpyXETjNKkffS9vcciaAreKDYSe5CuatCnrtw4FScMYcs9iHB2KiZGIBhVQGYVlXg==}
+ peerDependencies:
+ vue: ^3.5.13
+ zod: ^3.23.8
+
+ '@esbuild/aix-ppc64@0.27.2':
+ resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.2':
+ resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.2':
+ resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.2':
+ resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.2':
+ resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.2':
+ resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.2':
+ resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.2':
+ resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.2':
+ resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.2':
+ resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.2':
+ resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.2':
+ resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.2':
+ resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.2':
+ resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.2':
+ resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.2':
+ resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.2':
+ resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.2':
+ resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.2':
+ resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.2':
+ resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.2':
+ resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.2':
+ resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@rollup/rollup-android-arm-eabi@4.53.5':
+ resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.53.5':
+ resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.53.5':
+ resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.53.5':
+ resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.53.5':
+ resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.53.5':
+ resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
+ resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.5':
+ resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.5':
+ resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.53.5':
+ resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.5':
+ resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.5':
+ resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.5':
+ resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.5':
+ resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.5':
+ resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.53.5':
+ resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.53.5':
+ resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openharmony-arm64@4.53.5':
+ resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.5':
+ resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.5':
+ resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.53.5':
+ resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.53.5':
+ resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/node@24.10.4':
+ resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==}
+
+ '@vue/compiler-core@3.5.25':
+ resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==}
+
+ '@vue/compiler-dom@3.5.25':
+ resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==}
+
+ '@vue/compiler-sfc@3.5.25':
+ resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==}
+
+ '@vue/compiler-ssr@3.5.25':
+ resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==}
+
+ '@vue/reactivity@3.5.25':
+ resolution: {integrity: sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==}
+
+ '@vue/runtime-core@3.5.25':
+ resolution: {integrity: sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==}
+
+ '@vue/runtime-dom@3.5.25':
+ resolution: {integrity: sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==}
+
+ '@vue/server-renderer@3.5.25':
+ resolution: {integrity: sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==}
+ peerDependencies:
+ vue: 3.5.25
+
+ '@vue/shared@3.5.25':
+ resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.27.2:
+ resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.3:
+ resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.6:
+ resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.53.5:
+ resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ vite@7.3.0:
+ resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vue@3.5.25:
+ resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ zod@3.25.76:
+ resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+
+snapshots:
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.28.5':
+ dependencies:
+ '@babel/types': 7.28.5
+
+ '@babel/types@7.28.5':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@comfyorg/comfyui-frontend-types@1.36.3(vue@3.5.25(typescript@5.9.3))(zod@3.25.76)':
+ dependencies:
+ vue: 3.5.25(typescript@5.9.3)
+ zod: 3.25.76
+
+ '@esbuild/aix-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/android-arm@0.27.2':
+ optional: true
+
+ '@esbuild/android-x64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.2':
+ optional: true
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@rollup/rollup-android-arm-eabi@4.53.5':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.53.5':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.53.5':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.53.5':
+ optional: true
+
+ '@types/estree@1.0.8': {}
+
+ '@types/node@24.10.4':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@vue/compiler-core@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/shared': 3.5.25
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.25':
+ dependencies:
+ '@vue/compiler-core': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/compiler-sfc@3.5.25':
+ dependencies:
+ '@babel/parser': 7.28.5
+ '@vue/compiler-core': 3.5.25
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.6
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.25':
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/reactivity@3.5.25':
+ dependencies:
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-core@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/shared': 3.5.25
+
+ '@vue/runtime-dom@3.5.25':
+ dependencies:
+ '@vue/reactivity': 3.5.25
+ '@vue/runtime-core': 3.5.25
+ '@vue/shared': 3.5.25
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.25(vue@3.5.25(typescript@5.9.3))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.25
+ '@vue/shared': 3.5.25
+ vue: 3.5.25(typescript@5.9.3)
+
+ '@vue/shared@3.5.25': {}
+
+ csstype@3.2.3: {}
+
+ entities@4.5.0: {}
+
+ esbuild@0.27.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.2
+ '@esbuild/android-arm': 0.27.2
+ '@esbuild/android-arm64': 0.27.2
+ '@esbuild/android-x64': 0.27.2
+ '@esbuild/darwin-arm64': 0.27.2
+ '@esbuild/darwin-x64': 0.27.2
+ '@esbuild/freebsd-arm64': 0.27.2
+ '@esbuild/freebsd-x64': 0.27.2
+ '@esbuild/linux-arm': 0.27.2
+ '@esbuild/linux-arm64': 0.27.2
+ '@esbuild/linux-ia32': 0.27.2
+ '@esbuild/linux-loong64': 0.27.2
+ '@esbuild/linux-mips64el': 0.27.2
+ '@esbuild/linux-ppc64': 0.27.2
+ '@esbuild/linux-riscv64': 0.27.2
+ '@esbuild/linux-s390x': 0.27.2
+ '@esbuild/linux-x64': 0.27.2
+ '@esbuild/netbsd-arm64': 0.27.2
+ '@esbuild/netbsd-x64': 0.27.2
+ '@esbuild/openbsd-arm64': 0.27.2
+ '@esbuild/openbsd-x64': 0.27.2
+ '@esbuild/openharmony-arm64': 0.27.2
+ '@esbuild/sunos-x64': 0.27.2
+ '@esbuild/win32-arm64': 0.27.2
+ '@esbuild/win32-ia32': 0.27.2
+ '@esbuild/win32-x64': 0.27.2
+
+ estree-walker@2.0.2: {}
+
+ fdir@6.5.0(picomatch@4.0.3):
+ optionalDependencies:
+ picomatch: 4.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ nanoid@3.3.11: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.3: {}
+
+ postcss@8.5.6:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.53.5:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.53.5
+ '@rollup/rollup-android-arm64': 4.53.5
+ '@rollup/rollup-darwin-arm64': 4.53.5
+ '@rollup/rollup-darwin-x64': 4.53.5
+ '@rollup/rollup-freebsd-arm64': 4.53.5
+ '@rollup/rollup-freebsd-x64': 4.53.5
+ '@rollup/rollup-linux-arm-gnueabihf': 4.53.5
+ '@rollup/rollup-linux-arm-musleabihf': 4.53.5
+ '@rollup/rollup-linux-arm64-gnu': 4.53.5
+ '@rollup/rollup-linux-arm64-musl': 4.53.5
+ '@rollup/rollup-linux-loong64-gnu': 4.53.5
+ '@rollup/rollup-linux-ppc64-gnu': 4.53.5
+ '@rollup/rollup-linux-riscv64-gnu': 4.53.5
+ '@rollup/rollup-linux-riscv64-musl': 4.53.5
+ '@rollup/rollup-linux-s390x-gnu': 4.53.5
+ '@rollup/rollup-linux-x64-gnu': 4.53.5
+ '@rollup/rollup-linux-x64-musl': 4.53.5
+ '@rollup/rollup-openharmony-arm64': 4.53.5
+ '@rollup/rollup-win32-arm64-msvc': 4.53.5
+ '@rollup/rollup-win32-ia32-msvc': 4.53.5
+ '@rollup/rollup-win32-x64-gnu': 4.53.5
+ '@rollup/rollup-win32-x64-msvc': 4.53.5
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ typescript@5.9.3: {}
+
+ undici-types@7.16.0: {}
+
+ vite@7.3.0(@types/node@24.10.4):
+ dependencies:
+ esbuild: 0.27.2
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.53.5
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 24.10.4
+ fsevents: 2.3.3
+
+ vue@3.5.25(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.25
+ '@vue/compiler-sfc': 3.5.25
+ '@vue/runtime-dom': 3.5.25
+ '@vue/server-renderer': 3.5.25(vue@3.5.25(typescript@5.9.3))
+ '@vue/shared': 3.5.25
+ optionalDependencies:
+ typescript: 5.9.3
+
+ zod@3.25.76: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..efc037a
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+onlyBuiltDependencies:
+ - esbuild
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..570998c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,41 @@
+[project]
+name = "comfyui-node-organizer"
+description = "ComfyUI extension to organize nodes"
+version = "1.2.0"
+readme = "README.md"
+license = { file = "LICENSE" }
+
+[project.urls]
+Repository = "https://github.com/PBandDev/comfyui-node-organizer"
+
+[tool.comfy]
+PublisherId = "pbanddev"
+DisplayName = "Node Organizer"
+Icon = "https://raw.githubusercontent.com/PBandDev/comfyui-node-organizer/main/assets/cog.svg"
+requires-comfyui = ">=0.3.0"
+includes = ['dist/']
+
+[tool.bumpversion]
+current_version = "1.2.0"
+parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)"
+serialize = ["{major}.{minor}.{patch}"]
+commit = true
+tag = true
+tag_name = "v{new_version}"
+tag_message = "Release version {new_version}"
+message = "chore: bump version to {new_version} [skip ci]"
+
+[[tool.bumpversion.files]]
+filename = "pyproject.toml"
+search = 'version = "{current_version}"'
+replace = 'version = "{new_version}"'
+
+[[tool.bumpversion.files]]
+filename = "package.json"
+search = '"version": "{current_version}"'
+replace = '"version": "{new_version}"'
+
+[[tool.bumpversion.files]]
+filename = "src/index.ts"
+search = 'Version {current_version}'
+replace = 'Version {new_version}'
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..c9a8cd9
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,106 @@
+import type { ComfyApp } from "@comfyorg/comfyui-frontend-types";
+import type { LGraph } from "./layout/types";
+import { layoutGraph } from "./layout";
+
+/** Context menu option */
+interface ContextMenuOption {
+ content: string;
+ callback?: () => void;
+}
+
+/** LGraphCanvas type (available at runtime) */
+interface LGraphCanvasType {
+ prototype: {
+ getCanvasMenuOptions: () => (ContextMenuOption | null)[];
+ };
+}
+
+declare global {
+ const app: ComfyApp;
+ const LGraphCanvas: LGraphCanvasType;
+
+ interface Window {
+ app: ComfyApp;
+ }
+}
+
+/** Setting ID for debug logging */
+const SETTINGS_PREFIX = "Node Organizer";
+const SETTINGS_IDS = {
+ VERSION: `${SETTINGS_PREFIX}. ${SETTINGS_PREFIX}`,
+ DEBUG_LOGGING: `${SETTINGS_PREFIX}.Debug Logging`,
+};
+
+/** Check if debug logging is enabled */
+export function isDebugEnabled(): boolean {
+ try {
+ return (
+ app.extensionManager?.setting?.get(SETTINGS_IDS.DEBUG_LOGGING) ??
+ false
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** Log message only if debug logging is enabled */
+export function debugLog(message: string, ...args: unknown[]): void {
+ if (isDebugEnabled()) {
+ console.log(`[node-organizer] ${message}`, ...args);
+ }
+}
+
+app.registerExtension({
+ name: "ComfyUI Node Organizer",
+ settings: [
+ {
+ id: SETTINGS_IDS.VERSION,
+ name: "Version 1.0.0",
+ type: () => {
+ const spanEl = document.createElement("span");
+ spanEl.insertAdjacentHTML(
+ "beforeend",
+ `Homepage`
+ );
+
+ return spanEl;
+ },
+ defaultValue: undefined,
+ },
+ {
+ id: SETTINGS_IDS.DEBUG_LOGGING,
+ name: "Enable Debug Logging",
+ type: "boolean",
+ tooltip:
+ "Show detailed debug logs in browser console during workflow organization",
+ defaultValue: false,
+ },
+ ],
+ getCanvasMenuItems(canvas) {
+ return [
+ null, // separator
+ {
+ content: "Organize Workflow",
+ callback: () => {
+ const graph = canvas.getCurrentGraph() as LGraph | null;
+ if (!graph) {
+ console.warn("[node-organizer] No active graph");
+ return;
+ }
+
+ try {
+ const result = layoutGraph(graph);
+ console.log(
+ `[node-organizer] Layout complete: ${result.nodeCount} nodes, ` +
+ `${result.layerCount} layers, ${result.groupCount} groups, ` +
+ `${result.executionMs.toFixed(1)}ms`
+ );
+ } catch (err) {
+ console.error("[node-organizer] Layout failed:", err);
+ }
+ },
+ },
+ null, // separator
+ ];
+ },
+});
diff --git a/src/layout/bin-pack.ts b/src/layout/bin-pack.ts
new file mode 100644
index 0000000..db496cc
--- /dev/null
+++ b/src/layout/bin-pack.ts
@@ -0,0 +1,225 @@
+import type { PackedRow, LayoutConfig } from "./types";
+
+/** Node size info for packing */
+interface NodeSize {
+ id: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Pack nodes into rows using First-Fit Decreasing Height (FFDH) algorithm
+ *
+ * Strategy:
+ * 1. Sort nodes by height descending (tall nodes first)
+ * 2. For each node, try to fit into existing row
+ * 3. If no row fits, create new row
+ * 4. Rows are sorted by height for visual consistency
+ *
+ * @param nodeIds - Node IDs to pack
+ * @param sizes - Map of node ID to size
+ * @param config - Layout configuration
+ * @returns Array of packed rows
+ */
+export function packNodesIntoRows(
+ nodeIds: number[],
+ sizes: Map,
+ config: LayoutConfig
+): PackedRow[] {
+ if (nodeIds.length === 0) return [];
+
+ // maxColumns: 0=auto, 1=disabled (vertical stack), 2+=fixed columns
+ if (config.maxColumns === 1) {
+ // Vertical stacking - one node per row
+ return packVertically(nodeIds, sizes, config.verticalGap);
+ }
+
+ // Build sized node list
+ const nodeSizes: NodeSize[] = nodeIds.map((id) => {
+ const size = sizes.get(id);
+ return {
+ id,
+ width: size?.width ?? 200,
+ height: size?.height ?? 100,
+ };
+ });
+
+ // Sort by height descending (FFDH: tall nodes first)
+ nodeSizes.sort((a, b) => b.height - a.height);
+
+ if (config.maxColumns >= 2) {
+ // Fixed column count
+ return packFixedColumns(nodeSizes, config.maxColumns, config.verticalGap);
+ }
+
+ // Auto mode: use maxRowWidth
+ return packByWidth(nodeSizes, config.maxRowWidth, config.horizontalGap, config.verticalGap);
+}
+
+/**
+ * Pack nodes vertically (one per row) - legacy behavior
+ */
+function packVertically(
+ nodeIds: number[],
+ sizes: Map,
+ verticalGap: number
+): PackedRow[] {
+ const rows: PackedRow[] = [];
+ let yOffset = 0;
+
+ for (const id of nodeIds) {
+ const size = sizes.get(id);
+ const height = size?.height ?? 100;
+ const width = size?.width ?? 200;
+
+ rows.push({
+ nodes: [id],
+ height,
+ yOffset,
+ width,
+ });
+
+ yOffset += height + verticalGap;
+ }
+
+ return rows;
+}
+
+/**
+ * Pack nodes into fixed number of columns
+ * Distributes nodes round-robin by height to balance columns
+ */
+function packFixedColumns(
+ nodeSizes: NodeSize[],
+ columnCount: number,
+ verticalGap: number
+): PackedRow[] {
+ // Group nodes by similar height into rows
+ const rows: PackedRow[] = [];
+ let currentRow: NodeSize[] = [];
+
+ for (const node of nodeSizes) {
+ currentRow.push(node);
+
+ if (currentRow.length >= columnCount) {
+ // Row is full
+ const rowHeight = Math.max(...currentRow.map((n) => n.height));
+ const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
+
+ rows.push({
+ nodes: currentRow.map((n) => n.id),
+ height: rowHeight,
+ yOffset: 0, // Set later
+ width: rowWidth,
+ });
+
+ currentRow = [];
+ }
+ }
+
+ // Handle remaining nodes in last partial row
+ if (currentRow.length > 0) {
+ const rowHeight = Math.max(...currentRow.map((n) => n.height));
+ const rowWidth = currentRow.reduce((sum, n) => sum + n.width, 0);
+
+ rows.push({
+ nodes: currentRow.map((n) => n.id),
+ height: rowHeight,
+ yOffset: 0,
+ width: rowWidth,
+ });
+ }
+
+ // Calculate y offsets
+ let yOffset = 0;
+ for (const row of rows) {
+ row.yOffset = yOffset;
+ yOffset += row.height + verticalGap;
+ }
+
+ return rows;
+}
+
+/**
+ * Pack nodes by maximum row width (auto column count)
+ * Uses FFDH: First-Fit Decreasing Height
+ */
+function packByWidth(
+ nodeSizes: NodeSize[],
+ maxRowWidth: number,
+ horizontalGap: number,
+ verticalGap: number
+): PackedRow[] {
+ const rows: Array<{
+ nodes: NodeSize[];
+ height: number;
+ currentWidth: number;
+ }> = [];
+
+ for (const node of nodeSizes) {
+ // Try to fit into existing row
+ let placed = false;
+
+ for (const row of rows) {
+ const newWidth = row.currentWidth + horizontalGap + node.width;
+
+ // Check if node fits and heights are compatible
+ // Allow 50% height variance to keep rows visually balanced
+ const heightRatio = row.height > 0 ? node.height / row.height : 1;
+ const heightCompatible = heightRatio >= 0.5 && heightRatio <= 2.0;
+
+ if (newWidth <= maxRowWidth && heightCompatible) {
+ row.nodes.push(node);
+ row.currentWidth = newWidth;
+ row.height = Math.max(row.height, node.height);
+ placed = true;
+ break;
+ }
+ }
+
+ if (!placed) {
+ // Create new row
+ rows.push({
+ nodes: [node],
+ height: node.height,
+ currentWidth: node.width,
+ });
+ }
+ }
+
+ // Sort rows by their tallest node height (tallest first for visual hierarchy)
+ rows.sort((a, b) => b.height - a.height);
+
+ // Convert to PackedRow format with y offsets
+ const result: PackedRow[] = [];
+ let yOffset = 0;
+
+ for (const row of rows) {
+ result.push({
+ nodes: row.nodes.map((n) => n.id),
+ height: row.height,
+ yOffset,
+ width: row.currentWidth,
+ });
+
+ yOffset += row.height + verticalGap;
+ }
+
+ return result;
+}
+
+/**
+ * Calculate total height of packed rows
+ */
+export function getPackedHeight(rows: PackedRow[]): number {
+ if (rows.length === 0) return 0;
+ const lastRow = rows[rows.length - 1];
+ return lastRow.yOffset + lastRow.height;
+}
+
+/**
+ * Calculate max width across all packed rows
+ */
+export function getPackedWidth(rows: PackedRow[]): number {
+ return Math.max(0, ...rows.map((r) => r.width));
+}
diff --git a/src/layout/graph-builder.ts b/src/layout/graph-builder.ts
new file mode 100644
index 0000000..d79dc70
--- /dev/null
+++ b/src/layout/graph-builder.ts
@@ -0,0 +1,502 @@
+import type {
+ LGraph,
+ LGraphNode,
+ LGraphGroup,
+ LayoutNode,
+ LayoutGroup,
+ LayoutState,
+ LayoutConfig,
+ RerouteChain,
+} from "./types";
+import { DEFAULT_CONFIG } from "./types";
+import { classifyNodes } from "./node-classifier";
+import { findRerouteChains, getRerouteNodeIds, getVirtualEdges } from "./reroute-collapse";
+import { debugLog } from "../index";
+
+/**
+ * Runtime subgraph I/O node (has pos/size like regular nodes, not bounding)
+ */
+interface RuntimeIONode {
+ id: number;
+ pos: [number, number];
+ size: [number, number];
+}
+
+/**
+ * Check if an object looks like a subgraph I/O node at runtime
+ * Runtime has pos/size (not bounding like JSON export)
+ */
+function isValidIONode(obj: unknown): obj is RuntimeIONode {
+ if (!obj || typeof obj !== "object") return false;
+ const candidate = obj as Record;
+ // Must have numeric id
+ if (typeof candidate.id !== "number") return false;
+ // Use duck typing - pos/size may be getters returning array-like objects
+ const pos = candidate.pos as [number, number] | undefined;
+ const size = candidate.size as [number, number] | undefined;
+ if (!pos || typeof pos[0] !== "number" || typeof pos[1] !== "number") return false;
+ if (!size || typeof size[0] !== "number" || typeof size[1] !== "number") return false;
+ return true;
+}
+
+/**
+ * Convert runtime I/O node to LGraphNode-like object for layout
+ */
+function ioNodeToLGraphNode(ioNode: RuntimeIONode, type: "input" | "output"): LGraphNode {
+ return {
+ id: ioNode.id,
+ type: `subgraph_${type}`,
+ title: type === "input" ? "Input" : "Output",
+ pos: [ioNode.pos[0], ioNode.pos[1]],
+ size: [ioNode.size[0], ioNode.size[1]],
+ inputs: [],
+ outputs: [],
+ };
+}
+
+/** Title bar height for groups */
+const GROUP_TITLE_HEIGHT = 50;
+
+/**
+ * Check if outer group's bounds fully contain inner group's bounds
+ */
+function groupContainsGroup(outer: LGraphGroup, inner: LGraphGroup): boolean {
+ const ox = outer.pos[0];
+ const oy = outer.pos[1];
+ const ow = outer.size[0];
+ const oh = outer.size[1];
+
+ const ix = inner.pos[0];
+ const iy = inner.pos[1];
+ const iw = inner.size[0];
+ const ih = inner.size[1];
+
+ // Inner's bounds must be fully within outer's bounds
+ return ix >= ox && iy >= oy && ix + iw <= ox + ow && iy + ih <= oy + oh;
+}
+
+/**
+ * Build parent-child hierarchy for groups based on bounds containment
+ * Assigns parentGroup, childGroups, and depth to each LayoutGroup
+ */
+function buildGroupHierarchy(layoutGroups: LayoutGroup[]): void {
+ // For each group, find its parent (smallest containing group)
+ for (const inner of layoutGroups) {
+ let bestParent: LayoutGroup | null = null;
+ let bestArea = Infinity;
+
+ for (const outer of layoutGroups) {
+ if (outer === inner) continue;
+ if (groupContainsGroup(outer.group, inner.group)) {
+ const area = outer.group.size[0] * outer.group.size[1];
+ if (area < bestArea) {
+ bestArea = area;
+ bestParent = outer;
+ }
+ }
+ }
+
+ inner.parentGroup = bestParent;
+ if (bestParent) {
+ bestParent.childGroups.push(inner);
+ }
+ }
+
+ // Calculate depths using BFS from roots
+ const roots = layoutGroups.filter((g) => !g.parentGroup);
+ const queue: Array<{ group: LayoutGroup; depth: number }> = roots.map(
+ (g) => ({ group: g, depth: 0 })
+ );
+
+ while (queue.length > 0) {
+ const item = queue.shift();
+ if (!item) break;
+ const { group, depth } = item;
+ group.depth = depth;
+ for (const child of group.childGroups) {
+ queue.push({ group: child, depth: depth + 1 });
+ }
+ }
+}
+
+/**
+ * Check if a node's origin is inside a group's content area
+ */
+function nodeInsideGroup(node: LGraphNode, group: LGraphGroup): boolean {
+ const gx = group.pos[0];
+ const gy = group.pos[1] + GROUP_TITLE_HEIGHT;
+ const gw = group.size[0];
+ const gh = group.size[1] - GROUP_TITLE_HEIGHT;
+
+ const nx = node.pos?.[0] ?? 0;
+ const ny = node.pos?.[1] ?? 0;
+
+ return nx >= gx && nx < gx + gw && ny >= gy && ny < gy + gh;
+}
+
+/**
+ * Build layout graph from ComfyUI graph
+ * Groups are treated as "mega-nodes" with full hierarchy support
+ */
+export function buildLayoutGraph(
+ graph: LGraph,
+ config: Partial = {}
+): LayoutState {
+ const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
+ const nodes = new Map();
+ const groups: LayoutGroup[] = [];
+
+ // First pass: Extract all nodes
+ const graphNodes = graph._nodes ?? [];
+ const allNodes = new Map();
+
+ for (const node of graphNodes) {
+ if (!node || node.id === undefined) continue;
+ allNodes.set(node.id, node);
+ }
+
+ // Track subgraph I/O nodes separately (they need special position handling)
+ // Runtime I/O nodes have pos/size like regular nodes
+ const ioNodes = new Map();
+
+ // Check for subgraph I/O nodes (Subgraph extends LGraph)
+ const graphAny = graph as unknown as Record;
+ if (isValidIONode(graphAny.inputNode)) {
+ const inputNode = graphAny.inputNode as RuntimeIONode;
+ const inputLGraphNode = ioNodeToLGraphNode(inputNode, "input");
+ allNodes.set(inputNode.id, inputLGraphNode);
+ ioNodes.set(inputNode.id, inputNode);
+ debugLog(`Found subgraph input node id=${inputNode.id} at [${inputNode.pos}]`);
+ }
+ if (isValidIONode(graphAny.outputNode)) {
+ const outputNode = graphAny.outputNode as RuntimeIONode;
+ const outputLGraphNode = ioNodeToLGraphNode(outputNode, "output");
+ allNodes.set(outputNode.id, outputLGraphNode);
+ ioNodes.set(outputNode.id, outputNode);
+ debugLog(`Found subgraph output node id=${outputNode.id} at [${outputNode.pos}]`);
+ }
+
+ // Classify nodes into connected/disconnected/reroutes
+ const classified = classifyNodes(graph);
+ const disconnectedNodes = classified.disconnected;
+
+ // Find and collapse reroute chains if enabled
+ let rerouteChains: RerouteChain[] = [];
+ let collapsedRerouteIds = new Set();
+
+ if (fullConfig.collapseReroutes && classified.reroutes.size > 0) {
+ rerouteChains = findRerouteChains(graph, classified.reroutes);
+ collapsedRerouteIds = getRerouteNodeIds(rerouteChains);
+ }
+
+ // Second pass: Create LayoutGroup objects (without hierarchy yet)
+ const graphGroups = graph._groups ?? [];
+
+ for (const group of graphGroups) {
+ if (!group || !group.pos || !group.size) continue;
+
+ const layoutGroup: LayoutGroup = {
+ group,
+ memberIds: new Set(),
+ childGroups: [],
+ parentGroup: null,
+ depth: 0,
+ width: 0,
+ height: 0,
+ };
+
+ groups.push(layoutGroup);
+ }
+
+ // Third pass: Build group hierarchy (parent-child relationships)
+ buildGroupHierarchy(groups);
+
+ // Fourth pass: Assign nodes to their INNERMOST containing group
+ // A node belongs only to the deepest group that contains it
+ const nodeToGroup = new Map();
+
+ for (const [nodeId, node] of allNodes) {
+ let bestGroup: LayoutGroup | null = null;
+ let maxDepth = -1;
+
+ for (const layoutGroup of groups) {
+ if (nodeInsideGroup(node, layoutGroup.group)) {
+ if (layoutGroup.depth > maxDepth) {
+ maxDepth = layoutGroup.depth;
+ bestGroup = layoutGroup;
+ }
+ }
+ }
+
+ if (bestGroup) {
+ bestGroup.memberIds.add(nodeId);
+ nodeToGroup.set(nodeId, bestGroup);
+ }
+ }
+
+ // Log group membership
+ for (const layoutGroup of groups) {
+ const parentInfo = layoutGroup.parentGroup
+ ? ` (inside "${layoutGroup.parentGroup.group.title}")`
+ : "";
+ debugLog(
+ `Group "${layoutGroup.group.title}": ${layoutGroup.memberIds.size} direct members, ${layoutGroup.childGroups.length} child groups${parentInfo}`
+ );
+ }
+
+ // Fifth pass: Calculate group sizes bottom-up (deepest first)
+ // Build groupsByDepth array for bottom-up processing
+ const maxDepth = Math.max(0, ...groups.map((g) => g.depth));
+ const groupsByDepth: LayoutGroup[][] = [];
+
+ for (let d = 0; d <= maxDepth; d++) {
+ groupsByDepth.push(groups.filter((g) => g.depth === d));
+ }
+
+ // Process from deepest to shallowest
+ for (let depth = maxDepth; depth >= 0; depth--) {
+ for (const layoutGroup of groupsByDepth[depth]) {
+ let totalWidth = 0;
+ let totalHeight = 0;
+
+ // Use bounding box of member positions (more accurate than summing heights)
+ let minY = Infinity;
+ let maxY = -Infinity;
+ let hasMembers = false;
+
+ for (const nodeId of layoutGroup.memberIds) {
+ const node = allNodes.get(nodeId);
+ if (!node) continue;
+ hasMembers = true;
+ const w = node.size?.[0] ?? 200;
+ const h = node.size?.[1] ?? 100;
+ const ny = node.pos?.[1] ?? 0;
+ totalWidth = Math.max(totalWidth, w);
+ minY = Math.min(minY, ny);
+ maxY = Math.max(maxY, ny + h);
+ }
+
+ if (hasMembers && minY !== Infinity) {
+ totalHeight = maxY - minY;
+ }
+
+ // Include child groups (already calculated in previous iterations)
+ // Child groups add to height below members
+ let childGroupsHeight = 0;
+ for (const child of layoutGroup.childGroups) {
+ totalWidth = Math.max(totalWidth, child.width);
+ childGroupsHeight += child.height + fullConfig.verticalGap;
+ }
+ totalHeight += childGroupsHeight;
+
+ // Add padding and title
+ layoutGroup.width = totalWidth + fullConfig.groupPadding * 2;
+ layoutGroup.height =
+ totalHeight + fullConfig.groupPadding * 2 + GROUP_TITLE_HEIGHT;
+ }
+ }
+
+ // Sixth pass: Create LayoutNodes
+ // - Standalone connected nodes become regular layout nodes
+ // - Top-level groups get a representative node
+ // - Nested groups are handled internally by their parent
+ // - Disconnected nodes and collapsed reroutes are EXCLUDED (placed separately later)
+ const groupRepresentatives = new Map();
+
+ for (const [id, node] of allNodes) {
+ // Skip disconnected nodes - they'll be placed in a separate zone
+ if (disconnectedNodes.has(id)) {
+ continue;
+ }
+
+ // Skip collapsed reroute nodes - they'll be restored after layout
+ if (collapsedRerouteIds.has(id)) {
+ continue;
+ }
+
+ const group = nodeToGroup.get(id);
+
+ if (group) {
+ // This node is in a group
+ // Only create representative for top-level groups
+ const topLevelGroup = getTopLevelGroup(group);
+
+ if (!groupRepresentatives.has(topLevelGroup)) {
+ // First member becomes the representative for the top-level group
+ groupRepresentatives.set(topLevelGroup, id);
+
+ // Create layout node with TOP-LEVEL GROUP dimensions
+ nodes.set(id, {
+ node,
+ id,
+ layer: -1,
+ orderInLayer: 0,
+ predecessors: [],
+ successors: [],
+ width: topLevelGroup.width,
+ height: topLevelGroup.height,
+ x: 0,
+ y: 0,
+ isGroupRepresentative: true,
+ group: topLevelGroup,
+ });
+ }
+ // Skip non-representative group members - they'll be positioned relative to group
+ } else {
+ // Standalone node (or I/O node)
+ const runtimeIONode = ioNodes.get(id);
+ nodes.set(id, {
+ node,
+ id,
+ layer: -1,
+ orderInLayer: 0,
+ predecessors: [],
+ successors: [],
+ width: node.size?.[0] ?? 200,
+ height: node.size?.[1] ?? 100,
+ x: 0,
+ y: 0,
+ isSubgraphIO: runtimeIONode !== undefined,
+ runtimeIONode,
+ });
+ }
+ }
+
+ // Seventh pass: Build adjacency from links
+ // For group members, redirect connections to the top-level group representative
+ // Skip links involving collapsed reroutes (handled via virtual edges)
+ const links = graph.links;
+ if (links) {
+ // Handle different link storage formats:
+ // - Map (newer ComfyUI)
+ // - Record (object with numeric keys)
+ // - LLink[] (array, common in subgraphs)
+ let linkEntries: Iterable<[number, { origin_id: number; target_id: number } | null]>;
+
+ if (links instanceof Map) {
+ linkEntries = links.entries();
+ } else if (Array.isArray(links)) {
+ // Array of link objects (subgraph format)
+ linkEntries = links.map((link, i) => [i, link] as const);
+ } else {
+ // Object with numeric keys
+ linkEntries = Object.entries(links).map(([k, v]) => [Number(k), v] as const);
+ }
+
+ for (const [, link] of linkEntries) {
+ if (!link || typeof link !== "object") continue;
+ if (typeof link.origin_id !== "number" || typeof link.target_id !== "number") continue;
+
+ let sourceId = link.origin_id;
+ let targetId = link.target_id;
+
+ // Skip links involving collapsed reroutes (handled via virtual edges)
+ if (collapsedRerouteIds.has(sourceId) || collapsedRerouteIds.has(targetId)) {
+ continue;
+ }
+
+ // Redirect group members to their top-level group representative
+ const sourceGroup = nodeToGroup.get(sourceId);
+ const targetGroup = nodeToGroup.get(targetId);
+
+ if (sourceGroup) {
+ const topLevel = getTopLevelGroup(sourceGroup);
+ sourceId = groupRepresentatives.get(topLevel)!;
+ }
+ if (targetGroup) {
+ const topLevel = getTopLevelGroup(targetGroup);
+ targetId = groupRepresentatives.get(topLevel)!;
+ }
+
+ // Skip internal group connections (within same top-level group)
+ if (sourceGroup && targetGroup) {
+ const sourceTop = getTopLevelGroup(sourceGroup);
+ const targetTop = getTopLevelGroup(targetGroup);
+ if (sourceTop === targetTop) {
+ continue;
+ }
+ }
+
+ const sourceNode = nodes.get(sourceId);
+ const targetNode = nodes.get(targetId);
+
+ if (sourceNode && targetNode) {
+ if (!sourceNode.successors.includes(targetId)) {
+ sourceNode.successors.push(targetId);
+ }
+ if (!targetNode.predecessors.includes(sourceId)) {
+ targetNode.predecessors.push(sourceId);
+ }
+ }
+ }
+ }
+
+ // Eighth pass: Add virtual edges from collapsed reroute chains
+ if (rerouteChains.length > 0) {
+ const virtualEdges = getVirtualEdges(rerouteChains);
+
+ for (const [sourceId, targetIds] of virtualEdges) {
+ // Handle group redirection for source
+ const sourceGroup = nodeToGroup.get(sourceId);
+ let effectiveSourceId = sourceId;
+ if (sourceGroup) {
+ const topLevel = getTopLevelGroup(sourceGroup);
+ effectiveSourceId = groupRepresentatives.get(topLevel)!;
+ }
+
+ const sourceNode = nodes.get(effectiveSourceId);
+ if (!sourceNode) continue;
+
+ for (const targetId of targetIds) {
+ // Handle group redirection for target
+ const targetGroup = nodeToGroup.get(targetId);
+ let effectiveTargetId = targetId;
+ if (targetGroup) {
+ const topLevel = getTopLevelGroup(targetGroup);
+ effectiveTargetId = groupRepresentatives.get(topLevel)!;
+ }
+
+ const targetNode = nodes.get(effectiveTargetId);
+ if (!targetNode) continue;
+
+ // Skip if same group
+ if (sourceGroup && targetGroup) {
+ const sourceTop = getTopLevelGroup(sourceGroup);
+ const targetTop = getTopLevelGroup(targetGroup);
+ if (sourceTop === targetTop) continue;
+ }
+
+ if (!sourceNode.successors.includes(effectiveTargetId)) {
+ sourceNode.successors.push(effectiveTargetId);
+ }
+ if (!targetNode.predecessors.includes(effectiveSourceId)) {
+ targetNode.predecessors.push(effectiveSourceId);
+ }
+ }
+ }
+ }
+
+ return {
+ nodes,
+ layers: [],
+ groups,
+ config: fullConfig,
+ nodeToGroup,
+ groupRepresentatives,
+ allNodes,
+ groupsByDepth,
+ disconnectedNodes,
+ rerouteChains,
+ };
+}
+
+/**
+ * Get the top-level group (root of hierarchy) for a given group
+ */
+function getTopLevelGroup(group: LayoutGroup): LayoutGroup {
+ let current = group;
+ while (current.parentGroup) {
+ current = current.parentGroup;
+ }
+ return current;
+}
diff --git a/src/layout/groups.ts b/src/layout/groups.ts
new file mode 100644
index 0000000..4292bb5
--- /dev/null
+++ b/src/layout/groups.ts
@@ -0,0 +1,147 @@
+import type { LayoutState, LGraphGroup } from "./types";
+import { debugLog } from "../index";
+
+/** Title bar height for groups */
+const GROUP_TITLE_HEIGHT = 50;
+
+/**
+ * Resize groups to fit their contents after layout
+ * Uses bottom-up processing: deepest groups first, then parents
+ */
+export function resizeGroupsToFit(state: LayoutState): number {
+ const { groupsByDepth, allNodes, config } = state;
+
+ if (!groupsByDepth || !allNodes) {
+ debugLog("Missing groupsByDepth or allNodes");
+ return 0;
+ }
+
+ let resizedCount = 0;
+ const maxDepth = groupsByDepth.length - 1;
+
+ // Process from deepest to shallowest (bottom-up)
+ for (let depth = maxDepth; depth >= 0; depth--) {
+ for (const layoutGroup of groupsByDepth[depth]) {
+ const { group, memberIds, childGroups } = layoutGroup;
+
+ // Skip groups with no content
+ if (memberIds.size === 0 && childGroups.length === 0) {
+ debugLog(`Skipping empty group "${group.title}"`);
+ continue;
+ }
+
+ // Calculate bounding box from positioned members and child groups
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ // Include direct member nodes
+ for (const nodeId of memberIds) {
+ const node = allNodes.get(nodeId);
+ if (!node) continue;
+
+ const nx = node.pos?.[0] ?? 0;
+ const ny = node.pos?.[1] ?? 0;
+ const nw = node.size?.[0] ?? 200;
+ const nh = node.size?.[1] ?? 100;
+
+ minX = Math.min(minX, nx);
+ minY = Math.min(minY, ny);
+ maxX = Math.max(maxX, nx + nw);
+ maxY = Math.max(maxY, ny + nh);
+ }
+
+ // Include child groups (already resized in previous iterations)
+ for (const child of childGroups) {
+ const cg = child.group;
+ const cx = cg.pos[0];
+ const cy = cg.pos[1];
+ const cw = cg.size[0];
+ const ch = cg.size[1];
+
+ minX = Math.min(minX, cx);
+ minY = Math.min(minY, cy);
+ maxX = Math.max(maxX, cx + cw);
+ maxY = Math.max(maxY, cy + ch);
+ }
+
+ // Handle empty bounds (shouldn't happen but be safe)
+ if (minX === Infinity) {
+ debugLog(`No content bounds for group "${group.title}"`);
+ continue;
+ }
+
+ // Calculate final group bounds with padding
+ const padding = config.groupPadding;
+ const newX = minX - padding;
+ const newY = minY - padding - GROUP_TITLE_HEIGHT;
+ const newWidth = maxX - minX + padding * 2;
+ const newHeight = maxY - minY + padding * 2 + GROUP_TITLE_HEIGHT;
+
+ debugLog(
+ `Resizing group "${group.title}" (depth ${layoutGroup.depth}) to [${newX}, ${newY}, ${newWidth}, ${newHeight}]`
+ );
+
+ // Update group bounds
+ updateGroupBounds(group, newX, newY, newWidth, newHeight);
+
+ resizedCount++;
+ }
+ }
+
+ return resizedCount;
+}
+
+/**
+ * Update group bounds using multiple methods for compatibility
+ */
+function updateGroupBounds(
+ group: LGraphGroup,
+ x: number,
+ y: number,
+ width: number,
+ height: number
+): void {
+ // Method 1: Direct pos/size array mutation (most common)
+ if (Array.isArray(group.pos)) {
+ group.pos[0] = x;
+ group.pos[1] = y;
+ }
+ if (Array.isArray(group.size)) {
+ group.size[0] = width;
+ group.size[1] = height;
+ }
+
+ // Method 2: Try _pos/_size internal properties
+ const g = group as LGraphGroup & {
+ _pos?: [number, number];
+ _size?: [number, number];
+ };
+ if (Array.isArray(g._pos)) {
+ g._pos[0] = x;
+ g._pos[1] = y;
+ }
+ if (Array.isArray(g._size)) {
+ g._size[0] = width;
+ g._size[1] = height;
+ }
+
+ // Method 3: Update _bounding Rectangle if present
+ if (group._bounding) {
+ const b = group._bounding;
+ b.x = x;
+ b.y = y;
+ b.width = width;
+ b.height = height;
+ }
+
+ // Method 4: Try bounding array (older format)
+ const gAny = group as LGraphGroup & { bounding?: number[] };
+ if (Array.isArray(gAny.bounding)) {
+ gAny.bounding[0] = x;
+ gAny.bounding[1] = y;
+ gAny.bounding[2] = width;
+ gAny.bounding[3] = height;
+ }
+}
diff --git a/src/layout/index.ts b/src/layout/index.ts
new file mode 100644
index 0000000..f77642e
--- /dev/null
+++ b/src/layout/index.ts
@@ -0,0 +1,103 @@
+import type { LGraph, LayoutConfig, LayoutResult } from "./types";
+import { DEFAULT_CONFIG } from "./types";
+import { buildLayoutGraph } from "./graph-builder";
+import { assignLayers } from "./layer-assign";
+import { minimizeCrossings } from "./ordering";
+import {
+ assignLayerXPositions,
+ assignNodeYPositions,
+ recalculateLayerXPositions,
+ applyPositions,
+ compactVertically,
+ resolveAllOverlaps,
+} from "./positioning";
+import { resizeGroupsToFit } from "./groups";
+import { debugLog } from "../index";
+
+/**
+ * Main entry point: layout the entire graph
+ *
+ * Pipeline:
+ * 1. Build layout graph (classify nodes, collapse reroutes, filter disconnected)
+ * 2. Assign layers via longest-path algorithm
+ * 3. Minimize edge crossings via size-aware barycenter heuristic
+ * 4. Assign X/Y coordinates with bin packing
+ * 5. Compact vertically to reduce gaps
+ * 6. Apply positions (includes disconnected zone and reroute restoration)
+ * 7. Resize groups to fit their members
+ */
+export function layoutGraph(
+ graph: LGraph,
+ config?: Partial
+): LayoutResult {
+ const startTime = performance.now();
+ const fullConfig: LayoutConfig = { ...DEFAULT_CONFIG, ...config };
+
+ // Handle empty graph
+ const nodeList = graph._nodes ?? [];
+ if (nodeList.length === 0) {
+ return {
+ nodeCount: 0,
+ layerCount: 0,
+ groupCount: 0,
+ executionMs: 0,
+ };
+ }
+
+ // Phase 1: Build layout graph (includes classification and reroute collapse)
+ const state = buildLayoutGraph(graph, fullConfig);
+
+ // Phase 2: Assign layers
+ assignLayers(state);
+
+ // Phase 3: Minimize crossings (size-aware barycenter)
+ minimizeCrossings(state);
+
+ // Phase 4: Assign coordinates (with bin packing)
+ assignLayerXPositions(state);
+ assignNodeYPositions(state);
+
+ // Phase 4b: Recalculate X after bin packing updates maxWidth
+ // (bin packing may place multiple nodes in a row, making layer wider)
+ recalculateLayerXPositions(state);
+
+ // Phase 5: Compact vertically
+ compactVertically(state);
+
+ // Phase 6: Apply to ComfyUI nodes (includes disconnected and reroute handling)
+ applyPositions(state);
+
+ // Phase 7: Resize groups
+ const groupCount = resizeGroupsToFit(state);
+
+ // Phase 8: Resolve all overlaps (groups + nodes unified)
+ // Uses scanline algorithm with priority-based monotonic pushing
+ resolveAllOverlaps(state);
+
+ // Trigger canvas redraw
+ if (graph.setDirtyCanvas) {
+ graph.setDirtyCanvas(true, true);
+ }
+
+ const executionMs = performance.now() - startTime;
+
+ // Count total nodes including disconnected and reroutes
+ const disconnectedCount = state.disconnectedNodes?.size ?? 0;
+ const rerouteCount = state.rerouteChains?.reduce((sum, c) => sum + c.nodes.length, 0) ?? 0;
+ const totalNodes = state.nodes.size + disconnectedCount + rerouteCount;
+
+ debugLog(
+ `Layout complete: ${totalNodes} nodes (${state.nodes.size} in DAG, ${disconnectedCount} disconnected, ${rerouteCount} reroutes), ${state.layers.length} layers in ${executionMs.toFixed(1)}ms`
+ );
+
+ return {
+ nodeCount: totalNodes,
+ layerCount: state.layers.length,
+ groupCount,
+ executionMs,
+ };
+}
+
+// Re-export types and config
+export type { LayoutConfig, LayoutResult } from "./types";
+export { DEFAULT_CONFIG } from "./types";
diff --git a/src/layout/layer-assign.ts b/src/layout/layer-assign.ts
new file mode 100644
index 0000000..7f9be90
--- /dev/null
+++ b/src/layout/layer-assign.ts
@@ -0,0 +1,102 @@
+import type { LayoutState, LayoutNode, Layer } from "./types";
+
+/**
+ * Assign layers to nodes using longest-path algorithm
+ * Sources (no predecessors) get layer 0
+ * Each node gets max(predecessor layers) + 1
+ */
+export function assignLayers(state: LayoutState): void {
+ const { nodes } = state;
+
+ // Topological sort via Kahn's algorithm
+ const inDegree = new Map();
+ const queue: number[] = [];
+ const order: number[] = [];
+
+ // Initialize in-degrees
+ for (const [id, node] of nodes) {
+ inDegree.set(id, node.predecessors.length);
+ if (node.predecessors.length === 0) {
+ queue.push(id);
+ }
+ }
+
+ // Process nodes in topological order
+ while (queue.length > 0) {
+ const nodeId = queue.shift()!;
+ order.push(nodeId);
+
+ const node = nodes.get(nodeId)!;
+ for (const succId of node.successors) {
+ const deg = (inDegree.get(succId) ?? 1) - 1;
+ inDegree.set(succId, deg);
+ if (deg === 0) {
+ queue.push(succId);
+ }
+ }
+ }
+
+ // Handle cycles: add remaining nodes (shouldn't happen in valid DAG)
+ for (const [id] of nodes) {
+ if (!order.includes(id)) {
+ order.push(id);
+ }
+ }
+
+ // Assign layers via longest path from sources
+ for (const nodeId of order) {
+ const node = nodes.get(nodeId)!;
+
+ if (node.predecessors.length === 0) {
+ // Source node: layer 0
+ node.layer = 0;
+ } else {
+ // Max layer of predecessors + 1
+ let maxPredLayer = 0;
+ for (const predId of node.predecessors) {
+ const pred = nodes.get(predId);
+ if (pred && pred.layer >= 0) {
+ maxPredLayer = Math.max(maxPredLayer, pred.layer + 1);
+ }
+ }
+ node.layer = maxPredLayer;
+ }
+ }
+
+ // Ensure all nodes have valid layer (handle edge cases)
+ for (const node of nodes.values()) {
+ if (node.layer < 0) {
+ node.layer = 0;
+ }
+ }
+
+ // Build layer structures
+ const layerMap = new Map();
+ for (const node of nodes.values()) {
+ const arr = layerMap.get(node.layer) ?? [];
+ arr.push(node);
+ layerMap.set(node.layer, arr);
+ }
+
+ // Sort layers by index and create Layer objects
+ const sortedLayerIndices = [...layerMap.keys()].sort((a, b) => a - b);
+ const layers: Layer[] = [];
+
+ for (const index of sortedLayerIndices) {
+ const layerNodes = layerMap.get(index)!;
+ // Sort nodes in layer by ID for initial deterministic order
+ layerNodes.sort((a, b) => a.id - b.id);
+ layerNodes.forEach((n, i) => {
+ n.orderInLayer = i;
+ });
+
+ layers.push({
+ index,
+ nodes: layerNodes,
+ x: 0,
+ maxWidth: Math.max(...layerNodes.map((n) => n.width), 100),
+ });
+ }
+
+ state.layers = layers;
+}
diff --git a/src/layout/node-classifier.ts b/src/layout/node-classifier.ts
new file mode 100644
index 0000000..472a43c
--- /dev/null
+++ b/src/layout/node-classifier.ts
@@ -0,0 +1,79 @@
+import type { LGraph, LLink, ClassifiedNodes } from "./types";
+import { debugLog } from "../index";
+
+/**
+ * Iterate over links in a graph (handles Map, Record, and Array formats)
+ */
+function* iterateLinks(
+ links: Map | Record | LLink[]
+): Generator {
+ if (links instanceof Map) {
+ for (const link of links.values()) {
+ if (link) yield link;
+ }
+ } else if (Array.isArray(links)) {
+ for (const link of links) {
+ if (link) yield link;
+ }
+ } else {
+ for (const link of Object.values(links)) {
+ if (link) yield link;
+ }
+ }
+}
+
+/**
+ * Classify nodes into connected, disconnected, and reroutes
+ *
+ * - connected: nodes with at least one link
+ * - disconnected: nodes with no links (annotations, bookmarks, etc.)
+ * - reroutes: nodes with type "Reroute" (handled separately for chain collapse)
+ */
+export function classifyNodes(graph: LGraph): ClassifiedNodes {
+ const hasLinks = new Set();
+
+ // Mark all nodes that appear in any link
+ if (graph.links) {
+ for (const link of iterateLinks(graph.links)) {
+ if (typeof link.origin_id === "number") {
+ hasLinks.add(link.origin_id);
+ }
+ if (typeof link.target_id === "number") {
+ hasLinks.add(link.target_id);
+ }
+ }
+ }
+
+ const connected = new Set();
+ const disconnected = new Set();
+ const reroutes = new Set();
+
+ const nodes = graph._nodes ?? [];
+ for (const node of nodes) {
+ if (!node || node.id === undefined) continue;
+
+ const nodeType = node.type ?? "";
+
+ // Check if this is a reroute node
+ // Common reroute types: "Reroute", "Reroute (rgthree)", etc.
+ const isReroute = nodeType === "Reroute" || nodeType.startsWith("Reroute ");
+
+ if (isReroute) {
+ reroutes.add(node.id);
+ // Reroutes are also tracked as connected if they have links
+ if (hasLinks.has(node.id)) {
+ connected.add(node.id);
+ }
+ } else if (hasLinks.has(node.id)) {
+ connected.add(node.id);
+ } else {
+ disconnected.add(node.id);
+ }
+ }
+
+ debugLog(
+ `Classified: ${connected.size} connected, ${disconnected.size} disconnected, ${reroutes.size} reroutes`
+ );
+
+ return { connected, disconnected, reroutes };
+}
diff --git a/src/layout/ordering.ts b/src/layout/ordering.ts
new file mode 100644
index 0000000..d35209d
--- /dev/null
+++ b/src/layout/ordering.ts
@@ -0,0 +1,195 @@
+import type { LayoutState, Layer, LayoutNode } from "./types";
+
+/**
+ * Minimize edge crossings using size-aware barycenter heuristic
+ * Uses cumulative height positions instead of simple indices
+ * Alternates forward and backward sweeps
+ */
+export function minimizeCrossings(state: LayoutState): void {
+ const { layers, nodes, config } = state;
+
+ if (layers.length < 2) return;
+
+ // Initial ordering: sort by node height (tall nodes first reduces gaps)
+ for (const layer of layers) {
+ layer.nodes.sort((a, b) => b.height - a.height);
+ layer.nodes.forEach((n, i) => {
+ n.orderInLayer = i;
+ });
+ }
+
+ // Pre-compute cumulative height positions for size-aware barycenter
+ const heightPositions = new Map();
+ updateHeightPositions(layers, heightPositions, config.verticalGap);
+
+ // Iterative improvement with forward/backward sweeps
+ for (let iter = 0; iter < config.maxIterations; iter++) {
+ const improved =
+ iter % 2 === 0
+ ? sweepForward(layers, nodes, heightPositions, config.verticalGap)
+ : sweepBackward(layers, nodes, heightPositions, config.verticalGap);
+
+ if (!improved) break;
+ }
+}
+
+/**
+ * Compute cumulative height positions for each node
+ * This gives a position-like weight for size-aware ordering
+ */
+function updateHeightPositions(
+ layers: Layer[],
+ positions: Map,
+ verticalGap: number
+): void {
+ for (const layer of layers) {
+ let y = 0;
+ for (const node of layer.nodes) {
+ // Position is center of node
+ positions.set(node.id, y + node.height / 2);
+ y += node.height + verticalGap;
+ }
+ }
+}
+
+/**
+ * Forward sweep: order each layer based on predecessors in previous layer
+ * Uses height-aware positions for better size consideration
+ */
+function sweepForward(
+ layers: Layer[],
+ nodes: Map,
+ heightPositions: Map,
+ verticalGap: number
+): boolean {
+ let improved = false;
+
+ for (let i = 1; i < layers.length; i++) {
+ const currLayer = layers[i];
+
+ // Compute size-aware barycenter for each node
+ const barycenters = new Map();
+
+ for (const node of currLayer.nodes) {
+ if (node.predecessors.length > 0) {
+ let sum = 0;
+ let count = 0;
+
+ for (const predId of node.predecessors) {
+ const pred = nodes.get(predId);
+ if (pred && pred.layer === i - 1) {
+ // Use height position instead of simple index
+ const pos = heightPositions.get(predId) ?? pred.orderInLayer;
+ sum += pos;
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ barycenters.set(node.id, sum / count);
+ } else {
+ barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
+ }
+ } else {
+ barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
+ }
+ }
+
+ // Sort by barycenter, tie-break by ID for determinism
+ const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
+ currLayer.nodes.sort((a, b) => {
+ const aBar = barycenters.get(a.id) ?? 0;
+ const bBar = barycenters.get(b.id) ?? 0;
+ const diff = aBar - bBar;
+ return diff !== 0 ? diff : a.id - b.id;
+ });
+
+ // Reassign integer order
+ currLayer.nodes.forEach((n, idx) => {
+ n.orderInLayer = idx;
+ });
+
+ // Update height positions for next iteration
+ let y = 0;
+ for (const node of currLayer.nodes) {
+ heightPositions.set(node.id, y + node.height / 2);
+ y += node.height + verticalGap;
+ }
+
+ const newOrder = currLayer.nodes.map((n) => n.id).join(",");
+ if (oldOrder !== newOrder) improved = true;
+ }
+
+ return improved;
+}
+
+/**
+ * Backward sweep: order each layer based on successors in next layer
+ * Uses height-aware positions for better size consideration
+ */
+function sweepBackward(
+ layers: Layer[],
+ nodes: Map,
+ heightPositions: Map,
+ verticalGap: number
+): boolean {
+ let improved = false;
+
+ for (let i = layers.length - 2; i >= 0; i--) {
+ const currLayer = layers[i];
+
+ // Compute size-aware barycenter for each node based on successors
+ const barycenters = new Map();
+
+ for (const node of currLayer.nodes) {
+ if (node.successors.length > 0) {
+ let sum = 0;
+ let count = 0;
+
+ for (const succId of node.successors) {
+ const succ = nodes.get(succId);
+ if (succ && succ.layer === i + 1) {
+ // Use height position instead of simple index
+ const pos = heightPositions.get(succId) ?? succ.orderInLayer;
+ sum += pos;
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ barycenters.set(node.id, sum / count);
+ } else {
+ barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
+ }
+ } else {
+ barycenters.set(node.id, heightPositions.get(node.id) ?? 0);
+ }
+ }
+
+ // Sort by barycenter, tie-break by ID for determinism
+ const oldOrder = currLayer.nodes.map((n) => n.id).join(",");
+ currLayer.nodes.sort((a, b) => {
+ const aBar = barycenters.get(a.id) ?? 0;
+ const bBar = barycenters.get(b.id) ?? 0;
+ const diff = aBar - bBar;
+ return diff !== 0 ? diff : a.id - b.id;
+ });
+
+ // Reassign integer order
+ currLayer.nodes.forEach((n, idx) => {
+ n.orderInLayer = idx;
+ });
+
+ // Update height positions for next iteration
+ let y = 0;
+ for (const node of currLayer.nodes) {
+ heightPositions.set(node.id, y + node.height / 2);
+ y += node.height + verticalGap;
+ }
+
+ const newOrder = currLayer.nodes.map((n) => n.id).join(",");
+ if (oldOrder !== newOrder) improved = true;
+ }
+
+ return improved;
+}
diff --git a/src/layout/positioning.ts b/src/layout/positioning.ts
new file mode 100644
index 0000000..397db7a
--- /dev/null
+++ b/src/layout/positioning.ts
@@ -0,0 +1,901 @@
+import type { LayoutState, LayoutGroup, LGraphNode, LGraphGroup, LayoutConfig, PackedRow, LayoutNode } from "./types";
+
+/**
+ * Entity type for unified overlap resolution
+ * Represents either a group or a standalone node
+ */
+interface LayoutEntity {
+ id: string;
+ type: "group" | "node";
+ priority: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ group?: LGraphGroup;
+ node?: LGraphNode;
+ layoutGroup?: LayoutGroup;
+}
+import { packNodesIntoRows, getPackedWidth } from "./bin-pack";
+import { restoreReroutePositions } from "./reroute-collapse";
+import { debugLog } from "../index";
+
+/** Title bar height for groups */
+const GROUP_TITLE_HEIGHT = 50;
+
+/**
+ * Calculate disconnected zone width if there are disconnected nodes
+ */
+function getDisconnectedZoneWidth(state: LayoutState): number {
+ const { disconnectedNodes, allNodes, config } = state;
+
+ if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
+ return 0;
+ }
+
+ let maxWidth = 0;
+ for (const nodeId of disconnectedNodes) {
+ const node = allNodes.get(nodeId);
+ if (node) {
+ maxWidth = Math.max(maxWidth, node.size?.[0] ?? 200);
+ }
+ }
+ return maxWidth + config.disconnectedGap;
+}
+
+/**
+ * Assign X coordinates to layers
+ * Accounts for disconnected zone on the left if present
+ */
+export function assignLayerXPositions(state: LayoutState): void {
+ const { layers, config } = state;
+ const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
+
+ let x = config.startX + disconnectedZoneWidth;
+ for (const layer of layers) {
+ layer.x = x;
+ x += layer.maxWidth + config.horizontalGap;
+ }
+}
+
+/**
+ * Recalculate layer X positions after bin packing updates maxWidth
+ * Also updates node X positions to maintain their offset within each layer
+ * Must be called AFTER assignNodeYPositions which updates layer.maxWidth
+ */
+export function recalculateLayerXPositions(state: LayoutState): void {
+ const { layers, config } = state;
+ const disconnectedZoneWidth = getDisconnectedZoneWidth(state);
+
+ // Store each node's offset from its layer's current X
+ const nodeOffsets = new Map();
+ for (const layer of layers) {
+ for (const node of layer.nodes) {
+ nodeOffsets.set(node.id, node.x - layer.x);
+ }
+ }
+
+ // Recalculate layer X positions using updated maxWidth
+ let x = config.startX + disconnectedZoneWidth;
+ for (const layer of layers) {
+ layer.x = x;
+ x += layer.maxWidth + config.horizontalGap;
+ }
+
+ // Update node X positions based on new layer X + original offset
+ for (const layer of layers) {
+ for (const node of layer.nodes) {
+ const offset = nodeOffsets.get(node.id) ?? 0;
+ node.x = layer.x + offset;
+ }
+ }
+}
+
+/**
+ * Assign Y coordinates to nodes within each layer using bin packing
+ * Supports multi-column layout based on config.maxColumns
+ * Tracks reserved regions to prevent group/node overlaps within same layer
+ */
+export function assignNodeYPositions(state: LayoutState): void {
+ const { layers, config } = state;
+
+ // Store packed rows for reference
+ const packedRows = new Map();
+
+ for (const layer of layers) {
+ // Separate groups (placed first, full width) from standalone nodes
+ const groupNodes = layer.nodes.filter((n) => n.isGroupRepresentative);
+ const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
+
+ let y = config.startY;
+ const reservedRegions: Array<{ top: number; bottom: number }> = [];
+
+ // Position groups first, tracking their Y regions
+ for (const node of groupNodes) {
+ node.x = layer.x;
+ node.y = y;
+ reservedRegions.push({ top: y, bottom: y + node.height });
+ y += node.height + config.verticalGap;
+ }
+
+ // Bin pack standalone nodes, avoiding reserved regions
+ if (standaloneNodes.length > 0) {
+ // Build size map for bin packing
+ const sizes = new Map();
+ for (const node of standaloneNodes) {
+ sizes.set(node.id, { width: node.width, height: node.height });
+ }
+
+ // Pack nodes into rows
+ const nodeIds = standaloneNodes.map((n) => n.id);
+ const rows = packNodesIntoRows(nodeIds, sizes, config);
+
+ packedRows.set(layer.index, rows);
+
+ // Assign positions based on packed rows
+ for (const row of rows) {
+ let rowX = layer.x;
+ let rowY = y + row.yOffset;
+
+ // Check if row overlaps any reserved region
+ for (const region of reservedRegions) {
+ if (rowY < region.bottom && rowY + row.height > region.top) {
+ rowY = region.bottom + config.verticalGap;
+ }
+ }
+
+ for (const nodeId of row.nodes) {
+ const node = standaloneNodes.find((n) => n.id === nodeId);
+ if (node) {
+ node.x = rowX;
+ node.y = rowY;
+ rowX += node.width + config.horizontalGap;
+ }
+ }
+ }
+
+ // Update layer maxWidth to account for packed rows
+ const maxRowWidth = getPackedWidth(rows);
+ if (maxRowWidth > layer.maxWidth) {
+ layer.maxWidth = maxRowWidth;
+ }
+ }
+ }
+
+ state.packedRows = packedRows;
+}
+
+/**
+ * Place disconnected nodes in the left margin zone
+ * Uses bin packing for compact arrangement
+ */
+export function placeDisconnectedNodes(state: LayoutState): void {
+ const { disconnectedNodes, allNodes, nodeToGroup, config } = state;
+
+ if (!disconnectedNodes || disconnectedNodes.size === 0 || !allNodes) {
+ return;
+ }
+
+ // Filter out disconnected nodes that are inside groups - they're positioned with their group
+ const standaloneDisconnected: number[] = [];
+ for (const nodeId of disconnectedNodes) {
+ if (nodeToGroup?.has(nodeId)) {
+ continue; // Skip - this node is inside a group
+ }
+ standaloneDisconnected.push(nodeId);
+ }
+
+ if (standaloneDisconnected.length === 0) {
+ debugLog(`All ${disconnectedNodes.size} disconnected nodes are inside groups`);
+ return;
+ }
+
+ // Build size map
+ const sizes = new Map();
+ for (const nodeId of standaloneDisconnected) {
+ const node = allNodes.get(nodeId);
+ if (node) {
+ sizes.set(nodeId, {
+ width: node.size?.[0] ?? 200,
+ height: node.size?.[1] ?? 100,
+ });
+ }
+ }
+
+ // Pack into rows using config settings
+ const rows = packNodesIntoRows(standaloneDisconnected, sizes, {
+ ...config,
+ maxColumns: 1, // Stack vertically in left margin
+ });
+
+ // Position at startX, startY (left of main DAG)
+ const x = config.startX;
+ const y = config.startY;
+
+ for (const row of rows) {
+ for (const nodeId of row.nodes) {
+ const node = allNodes.get(nodeId);
+ if (node && node.pos) {
+ // Skip locked/pinned nodes
+ if (node.flags?.pinned || node.locked) {
+ continue;
+ }
+ node.pos[0] = x;
+ node.pos[1] = y + row.yOffset;
+ }
+ }
+ }
+
+ debugLog(
+ `Placed ${standaloneDisconnected.length} disconnected nodes in left margin (${disconnectedNodes.size - standaloneDisconnected.length} inside groups)`
+ );
+}
+
+/**
+ * Apply computed positions to actual ComfyUI nodes
+ * Handles standalone nodes, subgraph I/O nodes, top-level groups, and nested groups recursively
+ * Also restores reroute positions and places disconnected nodes
+ */
+export function applyPositions(state: LayoutState): void {
+ const { nodes, groupRepresentatives, allNodes, groups, config, rerouteChains } = state;
+
+ // First: Apply positions to standalone nodes (including I/O nodes)
+ for (const layoutNode of nodes.values()) {
+ if (layoutNode.isGroupRepresentative) {
+ // This is a group - handled below with recursive positioning
+ continue;
+ }
+
+ // Handle subgraph I/O nodes (update pos array directly)
+ if (layoutNode.isSubgraphIO && layoutNode.runtimeIONode) {
+ layoutNode.runtimeIONode.pos[0] = layoutNode.x;
+ layoutNode.runtimeIONode.pos[1] = layoutNode.y;
+ debugLog(
+ `Positioned I/O node id=${layoutNode.id} to [${layoutNode.x}, ${layoutNode.y}]`
+ );
+ continue;
+ }
+
+ const comfyNode = layoutNode.node;
+
+ // Skip locked/pinned nodes
+ if (comfyNode.flags?.pinned || comfyNode.locked) {
+ continue;
+ }
+
+ // Update position
+ if (comfyNode.pos) {
+ comfyNode.pos[0] = layoutNode.x;
+ comfyNode.pos[1] = layoutNode.y;
+ }
+ }
+
+ // Second: Position group contents recursively (top-level groups only)
+ if (groupRepresentatives && allNodes && groups) {
+ // Find top-level groups (no parent)
+ const topLevelGroups = groups.filter((g) => !g.parentGroup);
+
+ for (const topGroup of topLevelGroups) {
+ const repId = groupRepresentatives.get(topGroup);
+ if (repId === undefined) continue;
+
+ const repNode = nodes.get(repId);
+ if (!repNode) continue;
+
+ // Start positioning at the group's content area
+ const startX = repNode.x + config.groupPadding;
+ const startY = repNode.y + config.groupPadding + GROUP_TITLE_HEIGHT;
+
+ positionGroupContents(topGroup, startX, startY, allNodes, config);
+ }
+ }
+
+ // Third: Place disconnected nodes in left margin
+ placeDisconnectedNodes(state);
+
+ // Fourth: Restore reroute positions along their chain edges
+ if (rerouteChains && rerouteChains.length > 0 && allNodes) {
+ // Build positions and sizes maps from actual nodes
+ const positions = new Map();
+ const sizes = new Map();
+
+ for (const [id, node] of allNodes) {
+ if (node.pos) {
+ positions.set(id, { x: node.pos[0], y: node.pos[1] });
+ }
+ if (node.size) {
+ sizes.set(id, { width: node.size[0], height: node.size[1] });
+ }
+ }
+
+ // Also include layout node positions (for nodes we just positioned)
+ for (const layoutNode of nodes.values()) {
+ positions.set(layoutNode.id, { x: layoutNode.x, y: layoutNode.y });
+ sizes.set(layoutNode.id, { width: layoutNode.width, height: layoutNode.height });
+ }
+
+ // Restore reroute positions
+ restoreReroutePositions(rerouteChains, positions, sizes);
+
+ // Apply restored positions to actual nodes
+ for (const chain of rerouteChains) {
+ for (const rerouteId of chain.nodes) {
+ const pos = positions.get(rerouteId);
+ const node = allNodes.get(rerouteId);
+ if (pos && node && node.pos) {
+ node.pos[0] = pos.x;
+ node.pos[1] = pos.y;
+ }
+ }
+ }
+
+ debugLog(
+ `Restored ${rerouteChains.length} reroute chains`
+ );
+ }
+}
+
+/**
+ * Get internal edges between group members by examining node connections
+ * Returns array of [sourceId, targetId] pairs where both are in the group
+ */
+function getInternalEdges(
+ memberIds: Set,
+ allNodes: Map
+): Array<[number, number]> {
+ const edges: Array<[number, number]> = [];
+
+ // Build a map of linkId -> sourceNodeId for all member outputs
+ const linkToSource = new Map();
+ for (const memberId of memberIds) {
+ const node = allNodes.get(memberId);
+ if (!node?.outputs) continue;
+
+ for (const output of node.outputs) {
+ if (output.links) {
+ for (const linkId of output.links) {
+ linkToSource.set(linkId, memberId);
+ }
+ }
+ }
+ }
+
+ // Check each member's inputs to find internal connections
+ for (const memberId of memberIds) {
+ const node = allNodes.get(memberId);
+ if (!node?.inputs) continue;
+
+ for (const input of node.inputs) {
+ if (input.link !== null && input.link !== undefined) {
+ const sourceId = linkToSource.get(input.link);
+ if (sourceId !== undefined && memberIds.has(sourceId)) {
+ edges.push([sourceId, memberId]);
+ }
+ }
+ }
+ }
+
+ return edges;
+}
+
+/**
+ * Assign layers to group members using longest-path algorithm
+ * Returns members grouped by layer (index 0 = sources)
+ */
+function assignMemberLayers(
+ memberIds: Set,
+ edges: Array<[number, number]>,
+ allNodes: Map
+): LGraphNode[][] {
+ // Build adjacency and in-degree
+ const successors = new Map();
+ const inDegree = new Map();
+
+ for (const id of memberIds) {
+ successors.set(id, []);
+ inDegree.set(id, 0);
+ }
+
+ for (const [source, target] of edges) {
+ successors.get(source)!.push(target);
+ inDegree.set(target, (inDegree.get(target) ?? 0) + 1);
+ }
+
+ // Longest-path layer assignment via topological sort
+ const nodeLayer = new Map();
+ const queue: number[] = [];
+
+ // Start with sources (in-degree 0)
+ for (const id of memberIds) {
+ if ((inDegree.get(id) ?? 0) === 0) {
+ queue.push(id);
+ nodeLayer.set(id, 0);
+ }
+ }
+
+ while (queue.length > 0) {
+ const current = queue.shift()!;
+ const currentLayer = nodeLayer.get(current) ?? 0;
+
+ for (const succ of successors.get(current) ?? []) {
+ // Update successor layer to max of current paths
+ const succLayer = nodeLayer.get(succ) ?? 0;
+ nodeLayer.set(succ, Math.max(succLayer, currentLayer + 1));
+
+ // Decrease in-degree and add to queue when all predecessors processed
+ const newInDegree = (inDegree.get(succ) ?? 1) - 1;
+ inDegree.set(succ, newInDegree);
+ if (newInDegree === 0) {
+ queue.push(succ);
+ }
+ }
+ }
+
+ // Handle any unvisited nodes (cycles or disconnected)
+ for (const id of memberIds) {
+ if (!nodeLayer.has(id)) {
+ nodeLayer.set(id, 0);
+ }
+ }
+
+ // Group by layer
+ const maxLayer = Math.max(...nodeLayer.values(), 0);
+ const layers: LGraphNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
+
+ for (const id of memberIds) {
+ const node = allNodes.get(id);
+ if (node) {
+ const layer = nodeLayer.get(id) ?? 0;
+ layers[layer].push(node);
+ }
+ }
+
+ return layers;
+}
+
+/**
+ * Re-layout group contents using proper Sugiyama-style layout for connected members
+ * or bin packing for disconnected members
+ * Returns the final Y position after all contents
+ */
+function positionGroupContents(
+ group: LayoutGroup,
+ startX: number,
+ startY: number,
+ allNodes: Map,
+ config: LayoutConfig
+): number {
+ // Collect non-locked members
+ const members: LGraphNode[] = [];
+ for (const nodeId of group.memberIds) {
+ const node = allNodes.get(nodeId);
+ if (!node) continue;
+ if (node.flags?.pinned || node.locked) continue;
+ members.push(node);
+ }
+
+ // Handle empty group
+ if (members.length === 0 && group.childGroups.length === 0) {
+ return startY;
+ }
+
+ debugLog(`layoutGroupContents "${group.group.title}": startX=${startX}, members=${members.length}, childGroups=${group.childGroups.length}`);
+
+ // Get internal edges between members
+ const edges = getInternalEdges(group.memberIds, allNodes);
+
+ let finalY = startY;
+
+ if (members.length > 0) {
+ if (edges.length > 0) {
+ // Has internal connections - use layer-based layout
+ const layers = assignMemberLayers(group.memberIds, edges, allNodes);
+
+ let x = startX;
+ for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
+ const layer = layers[layerIdx];
+ if (layer.length === 0) continue;
+
+ // Sort layer by height (tallest first for stable layout)
+ layer.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
+
+ let y = startY;
+ let layerMaxWidth = 0;
+
+ for (const node of layer) {
+ if (node.pos) {
+ node.pos[0] = x;
+ node.pos[1] = y;
+ debugLog(` Member ${node.id} (${node.type}): layer ${layerIdx}, pos [${x}, ${y}]`);
+ }
+
+ layerMaxWidth = Math.max(layerMaxWidth, node.size?.[0] ?? 200);
+ y += (node.size?.[1] ?? 100) + config.verticalGap;
+ }
+
+ finalY = Math.max(finalY, y - config.verticalGap);
+ x += layerMaxWidth + config.horizontalGap;
+ }
+ } else {
+ // No internal connections - use simple vertical stacking with bin pack
+ // Sort by height for consistent layout
+ members.sort((a, b) => (b.size?.[1] ?? 100) - (a.size?.[1] ?? 100));
+
+ // Build sizes map
+ const sizes = new Map();
+ for (const node of members) {
+ sizes.set(node.id, {
+ width: node.size?.[0] ?? 200,
+ height: node.size?.[1] ?? 100,
+ });
+ }
+
+ // Pack into rows
+ const nodeIds = members.map((n) => n.id);
+ const rows = packNodesIntoRows(nodeIds, sizes, config);
+
+ let y = startY;
+ for (const row of rows) {
+ let x = startX;
+ for (const nodeId of row.nodes) {
+ const node = allNodes.get(nodeId);
+ if (node?.pos) {
+ node.pos[0] = x;
+ node.pos[1] = y;
+ debugLog(` Member ${node.id} (${node.type}): packed pos [${x}, ${y}]`);
+ x += (node.size?.[0] ?? 200) + config.horizontalGap;
+ }
+ }
+ y += row.height + config.verticalGap;
+ }
+ finalY = y - config.verticalGap;
+ }
+ }
+
+ // Position child groups after members
+ for (const child of group.childGroups) {
+ // Calculate child group's content area
+ const childStartX = startX;
+ const childStartY = finalY + config.verticalGap;
+
+ // First, layout the child group's contents
+ const childEndY = positionGroupContents(child, childStartX, childStartY + GROUP_TITLE_HEIGHT, allNodes, config);
+
+ // Update child group position
+ if (child.group.pos) {
+ child.group.pos[0] = childStartX - config.groupPadding;
+ child.group.pos[1] = childStartY - config.groupPadding;
+ debugLog(` Child group "${child.group.title}": pos [${child.group.pos[0]}, ${child.group.pos[1]}]`);
+ }
+
+ finalY = childEndY + config.groupPadding;
+ }
+
+ return finalY;
+}
+
+/**
+ * Calculate vertical bounds of nodes in a layer
+ * Returns actual min/max Y by iterating all nodes, not assuming array order
+ * (Array order is by barycenter, not Y position after bin-packing)
+ */
+function getLayerBounds(nodes: LayoutNode[]): { minY: number; maxY: number } | null {
+ if (nodes.length === 0) return null;
+
+ let minY = Infinity;
+ let maxY = -Infinity;
+
+ for (const node of nodes) {
+ minY = Math.min(minY, node.y);
+ maxY = Math.max(maxY, node.y + node.height);
+ }
+
+ if (minY === Infinity) return null;
+ return { minY, maxY };
+}
+
+/**
+ * Compact vertical layout to reduce gaps
+ * Centers layers against the tallest non-group layer
+ * Groups are excluded from max calculation to prevent Y explosion
+ */
+export function compactVertically(state: LayoutState): void {
+ const { layers } = state;
+
+ // Calculate max height from STANDALONE nodes only (exclude groups)
+ // Groups are outliers that can be 4000px+, which would push all other layers down
+ let maxTotalHeight = 0;
+
+ for (const layer of layers) {
+ const standaloneNodes = layer.nodes.filter((n) => !n.isGroupRepresentative);
+
+ if (standaloneNodes.length === 0) {
+ // Layer is all groups, skip from max calculation
+ continue;
+ }
+
+ const bounds = getLayerBounds(standaloneNodes);
+ if (!bounds) continue;
+
+ const layerHeight = bounds.maxY - bounds.minY;
+ maxTotalHeight = Math.max(maxTotalHeight, layerHeight);
+ }
+
+ // If no standalone layers, skip centering entirely
+ if (maxTotalHeight === 0) return;
+
+ // Center each layer against maxTotalHeight
+ for (const layer of layers) {
+ if (layer.nodes.length === 0) continue;
+
+ const bounds = getLayerBounds(layer.nodes);
+ if (!bounds) continue;
+
+ const layerHeight = bounds.maxY - bounds.minY;
+ const offset = (maxTotalHeight - layerHeight) / 2;
+
+ for (const node of layer.nodes) {
+ node.y += offset;
+ }
+ }
+}
+
+/**
+ * Collect all entities (groups and standalone nodes) for overlap resolution
+ * Assigns priority: groups=100, connected nodes=50, disconnected=10
+ */
+function collectEntities(state: LayoutState): LayoutEntity[] {
+ const entities: LayoutEntity[] = [];
+ const { groups, allNodes, nodeToGroup, disconnectedNodes } = state;
+
+ if (!allNodes) return entities;
+
+ // Add top-level groups (high priority - they anchor)
+ for (const lg of groups ?? []) {
+ if (lg.parentGroup) continue; // Skip nested, handled with parent
+ entities.push({
+ id: `group_${lg.group.title ?? entities.length}`,
+ type: "group",
+ priority: 100,
+ x: lg.group.pos?.[0] ?? 0,
+ y: lg.group.pos?.[1] ?? 0,
+ width: lg.group.size?.[0] ?? 0,
+ height: lg.group.size?.[1] ?? 0,
+ group: lg.group,
+ layoutGroup: lg,
+ });
+ }
+
+ // Add standalone nodes (lower priority)
+ for (const [nodeId, node] of allNodes) {
+ if (nodeToGroup?.has(nodeId)) continue; // Inside group
+ if (node.flags?.pinned || node.locked) continue;
+
+ const isDisconnected = disconnectedNodes?.has(nodeId) ?? false;
+ entities.push({
+ id: `node_${nodeId}`,
+ type: "node",
+ priority: isDisconnected ? 10 : 50,
+ x: node.pos?.[0] ?? 0,
+ y: node.pos?.[1] ?? 0,
+ width: node.size?.[0] ?? 200,
+ height: node.size?.[1] ?? 100,
+ node,
+ });
+ }
+
+ return entities;
+}
+
+/**
+ * Apply position shift to an entity (group or node)
+ * For groups, also shifts all member nodes and child groups
+ */
+function applyEntityShift(
+ entity: LayoutEntity,
+ dx: number,
+ dy: number,
+ allNodes: Map
+): void {
+ if (entity.type === "group" && entity.group && entity.layoutGroup) {
+ // Shift group position
+ if (entity.group.pos) {
+ entity.group.pos[0] += dx;
+ entity.group.pos[1] += dy;
+ }
+ // Shift all member nodes
+ for (const nodeId of entity.layoutGroup.memberIds) {
+ const node = allNodes.get(nodeId);
+ if (node?.pos) {
+ node.pos[0] += dx;
+ node.pos[1] += dy;
+ }
+ }
+ // Shift child groups recursively
+ shiftChildGroups(entity.layoutGroup, dx, dy, allNodes);
+ } else if (entity.type === "node" && entity.node?.pos) {
+ entity.node.pos[0] += dx;
+ entity.node.pos[1] += dy;
+ }
+}
+
+/**
+ * Scanline overlap resolution for X-axis (push right)
+ * Entities are sorted by X, overlapping entities are pushed right
+ * Higher priority entities anchor, lower priority pushed
+ * Returns true if any entity was moved
+ */
+function scanlineResolveX(
+ entities: LayoutEntity[],
+ config: LayoutConfig,
+ allNodes: Map
+): boolean {
+ let anyChanged = false;
+ let iterations = 0;
+ const maxIterations = entities.length * 2;
+
+ let changed = true;
+ while (changed && iterations < maxIterations) {
+ changed = false;
+ iterations++;
+
+ // Sort by X position, then by priority (higher priority = earlier in sort)
+ entities.sort((a, b) => a.x - b.x || b.priority - a.priority);
+
+ for (let i = 0; i < entities.length; i++) {
+ const anchor = entities[i];
+
+ for (let j = i + 1; j < entities.length; j++) {
+ const pushed = entities[j];
+
+ // Check Y overlap (must overlap on Y axis to need X separation)
+ const yOverlap =
+ anchor.y < pushed.y + pushed.height + config.verticalGap &&
+ pushed.y < anchor.y + anchor.height + config.verticalGap;
+ if (!yOverlap) continue;
+
+ // Check X overlap
+ const requiredX = anchor.x + anchor.width + config.horizontalGap;
+ if (pushed.x < requiredX) {
+ // Overlap! Push entity j to the right
+ const dx = requiredX - pushed.x;
+ pushed.x = requiredX;
+ applyEntityShift(pushed, dx, 0, allNodes);
+ changed = true;
+ anyChanged = true;
+ debugLog(`Scanline X: ${pushed.id} right ${dx.toFixed(0)}px (avoid ${anchor.id})`);
+ }
+ }
+ }
+ }
+
+ if (iterations > 1) {
+ debugLog(`X-axis scanline: ${iterations} iterations`);
+ }
+ return anyChanged;
+}
+
+/**
+ * Scanline overlap resolution for Y-axis (push down)
+ * Entities are sorted by Y, overlapping entities are pushed down
+ * Higher priority entities anchor, lower priority pushed
+ * Returns true if any entity was moved
+ */
+function scanlineResolveY(
+ entities: LayoutEntity[],
+ config: LayoutConfig,
+ allNodes: Map
+): boolean {
+ let anyChanged = false;
+ let iterations = 0;
+ const maxIterations = entities.length * 2;
+
+ let changed = true;
+ while (changed && iterations < maxIterations) {
+ changed = false;
+ iterations++;
+
+ // Sort by Y position, then by priority (higher priority = earlier)
+ entities.sort((a, b) => a.y - b.y || b.priority - a.priority);
+
+ for (let i = 0; i < entities.length; i++) {
+ const anchor = entities[i];
+
+ for (let j = i + 1; j < entities.length; j++) {
+ const pushed = entities[j];
+
+ // Check X overlap (must overlap on X axis to need Y separation)
+ const xOverlap =
+ anchor.x < pushed.x + pushed.width + config.horizontalGap &&
+ pushed.x < anchor.x + anchor.width + config.horizontalGap;
+ if (!xOverlap) continue;
+
+ // Check Y overlap
+ const requiredY = anchor.y + anchor.height + config.verticalGap;
+ if (pushed.y < requiredY) {
+ // Overlap! Push entity j down
+ const dy = requiredY - pushed.y;
+ pushed.y = requiredY;
+ applyEntityShift(pushed, 0, dy, allNodes);
+ changed = true;
+ anyChanged = true;
+ debugLog(`Scanline Y: ${pushed.id} down ${dy.toFixed(0)}px (avoid ${anchor.id})`);
+ }
+ }
+ }
+ }
+
+ if (iterations > 1) {
+ debugLog(`Y-axis scanline: ${iterations} iterations`);
+ }
+ return anyChanged;
+}
+
+/**
+ * Recursively shift all member nodes and nested child groups
+ */
+function shiftGroupContentsRecursively(
+ layoutGroup: LayoutGroup,
+ dx: number,
+ dy: number,
+ allNodes: Map
+): void {
+ // Shift member nodes of this group
+ for (const nodeId of layoutGroup.memberIds) {
+ const node = allNodes.get(nodeId);
+ if (node?.pos) {
+ node.pos[0] += dx;
+ node.pos[1] += dy;
+ }
+ }
+ // Shift child groups and their contents recursively
+ for (const child of layoutGroup.childGroups) {
+ if (child.group.pos) {
+ child.group.pos[0] += dx;
+ child.group.pos[1] += dy;
+ }
+ shiftGroupContentsRecursively(child, dx, dy, allNodes);
+ }
+}
+
+/**
+ * Recursively shift child groups and their members
+ */
+function shiftChildGroups(
+ layoutGroup: LayoutGroup,
+ dx: number,
+ dy: number,
+ allNodes: Map
+): void {
+ for (const child of layoutGroup.childGroups) {
+ if (child.group.pos) {
+ child.group.pos[0] += dx;
+ child.group.pos[1] += dy;
+ }
+ shiftGroupContentsRecursively(child, dx, dy, allNodes);
+ }
+}
+
+/**
+ * Unified overlap resolution using scanline algorithm
+ * Uses priority-based monotonic pushing (right/down only) to prevent oscillation
+ * Groups have highest priority and anchor in place
+ */
+export function resolveAllOverlaps(state: LayoutState): void {
+ const { allNodes, config } = state;
+ if (!allNodes) return;
+
+ const entities = collectEntities(state);
+ if (entities.length < 2) {
+ debugLog(`Overlap resolution: ${entities.length} entities (skipped)`);
+ return;
+ }
+
+ // Pass 1: X-axis (left-to-right pushing)
+ scanlineResolveX(entities, config, allNodes);
+
+ // Pass 2: Y-axis (top-to-bottom pushing)
+ scanlineResolveY(entities, config, allNodes);
+
+ debugLog(`Overlap resolution: ${entities.length} entities processed`);
+}
diff --git a/src/layout/reroute-collapse.ts b/src/layout/reroute-collapse.ts
new file mode 100644
index 0000000..74f302a
--- /dev/null
+++ b/src/layout/reroute-collapse.ts
@@ -0,0 +1,277 @@
+import type { LGraph, LLink, RerouteChain } from "./types";
+import { debugLog } from "../index";
+
+/** Link info for chain detection */
+interface LinkInfo {
+ linkId: number;
+ originId: number;
+ originSlot: number;
+ targetId: number;
+ targetSlot: number;
+}
+
+/**
+ * Iterate over links in a graph (handles Map, Record, and Array formats)
+ */
+function* iterateLinks(
+ links: Map | Record | LLink[]
+): Generator {
+ if (links instanceof Map) {
+ for (const link of links.values()) {
+ if (link) yield link;
+ }
+ } else if (Array.isArray(links)) {
+ for (const link of links) {
+ if (link) yield link;
+ }
+ } else {
+ for (const link of Object.values(links)) {
+ if (link) yield link;
+ }
+ }
+}
+
+/**
+ * Find reroute chains in the graph
+ *
+ * A chain is a sequence of reroute nodes connected linearly:
+ * Source -> Reroute1 -> Reroute2 -> ... -> Target(s)
+ *
+ * Returns chains that can be collapsed into virtual edges
+ */
+export function findRerouteChains(
+ graph: LGraph,
+ reroutes: Set
+): RerouteChain[] {
+ if (!graph.links || reroutes.size === 0) return [];
+
+ // Build link info maps
+ // incomingLinks: target -> [links pointing to it]
+ // outgoingLinks: origin -> [links from it]
+ const incomingLinks = new Map();
+ const outgoingLinks = new Map();
+
+ for (const link of iterateLinks(graph.links)) {
+ if (!link || typeof link.origin_id !== "number" || typeof link.target_id !== "number") {
+ continue;
+ }
+
+ const info: LinkInfo = {
+ linkId: link.id,
+ originId: link.origin_id,
+ originSlot: link.origin_slot,
+ targetId: link.target_id,
+ targetSlot: link.target_slot,
+ };
+
+ if (!incomingLinks.has(link.target_id)) {
+ incomingLinks.set(link.target_id, []);
+ }
+ incomingLinks.get(link.target_id)!.push(info);
+
+ if (!outgoingLinks.has(link.origin_id)) {
+ outgoingLinks.set(link.origin_id, []);
+ }
+ outgoingLinks.get(link.origin_id)!.push(info);
+ }
+
+ // Find chain starts: reroutes that are fed by a non-reroute
+ const visitedReroutes = new Set();
+ const chains: RerouteChain[] = [];
+
+ for (const rerouteId of reroutes) {
+ if (visitedReroutes.has(rerouteId)) continue;
+
+ const incoming = incomingLinks.get(rerouteId) ?? [];
+
+ // Find non-reroute sources feeding this reroute
+ for (const link of incoming) {
+ if (reroutes.has(link.originId)) {
+ // Source is also a reroute, this isn't a chain start
+ continue;
+ }
+
+ // Found a chain start: non-reroute -> reroute
+ const chain = traceRerouteChain(
+ rerouteId,
+ link.originId,
+ link.originSlot,
+ reroutes,
+ outgoingLinks,
+ visitedReroutes
+ );
+
+ if (chain.nodes.length > 0) {
+ chains.push(chain);
+ }
+ }
+ }
+
+ debugLog(`Found ${chains.length} reroute chains`);
+
+ return chains;
+}
+
+/**
+ * Trace a reroute chain from start to end
+ */
+function traceRerouteChain(
+ startRerouteId: number,
+ sourceNode: number,
+ sourceSlot: number,
+ reroutes: Set,
+ outgoingLinks: Map,
+ visitedReroutes: Set
+): RerouteChain {
+ const chainNodes: number[] = [];
+ const targets: Array<{ node: number; slot: number }> = [];
+
+ let currentId = startRerouteId;
+
+ while (reroutes.has(currentId) && !visitedReroutes.has(currentId)) {
+ visitedReroutes.add(currentId);
+ chainNodes.push(currentId);
+
+ const outgoing = outgoingLinks.get(currentId) ?? [];
+
+ if (outgoing.length === 0) {
+ // Dead end reroute (no outgoing connections)
+ break;
+ }
+
+ // Check where the reroute goes
+ let nextReroute: number | null = null;
+
+ for (const link of outgoing) {
+ if (reroutes.has(link.targetId) && !visitedReroutes.has(link.targetId)) {
+ // Continues to another reroute
+ nextReroute = link.targetId;
+ } else if (!reroutes.has(link.targetId)) {
+ // Goes to a non-reroute target
+ targets.push({
+ node: link.targetId,
+ slot: link.targetSlot,
+ });
+ }
+ }
+
+ if (nextReroute !== null) {
+ currentId = nextReroute;
+ } else {
+ // Chain ends here
+ break;
+ }
+ }
+
+ return {
+ nodes: chainNodes,
+ sourceNode,
+ sourceSlot,
+ targets,
+ };
+}
+
+/**
+ * Create virtual edges that bypass reroute chains
+ *
+ * For layout purposes, we replace:
+ * Source -> Reroute1 -> Reroute2 -> Target
+ * With:
+ * Source -> Target (virtual edge)
+ *
+ * @returns Map of source node to target nodes (for DAG building)
+ */
+export function getVirtualEdges(
+ chains: RerouteChain[]
+): Map> {
+ const virtualEdges = new Map>();
+
+ for (const chain of chains) {
+ if (!virtualEdges.has(chain.sourceNode)) {
+ virtualEdges.set(chain.sourceNode, new Set());
+ }
+
+ for (const target of chain.targets) {
+ virtualEdges.get(chain.sourceNode)!.add(target.node);
+ }
+ }
+
+ return virtualEdges;
+}
+
+/**
+ * Position reroute nodes along their chain edges after layout
+ *
+ * Distributes reroutes evenly between source and target positions
+ */
+export function restoreReroutePositions(
+ chains: RerouteChain[],
+ positions: Map,
+ sizes: Map
+): void {
+ for (const chain of chains) {
+ if (chain.nodes.length === 0 || chain.targets.length === 0) continue;
+
+ const sourcePos = positions.get(chain.sourceNode);
+ const sourceSize = sizes.get(chain.sourceNode);
+ if (!sourcePos || !sourceSize) continue;
+
+ // Calculate average target position
+ let avgTargetX = 0;
+ let avgTargetY = 0;
+ let validTargets = 0;
+
+ for (const target of chain.targets) {
+ const targetPos = positions.get(target.node);
+ if (targetPos) {
+ avgTargetX += targetPos.x;
+ avgTargetY += targetPos.y;
+ validTargets++;
+ }
+ }
+
+ if (validTargets === 0) continue;
+
+ avgTargetX /= validTargets;
+ avgTargetY /= validTargets;
+
+ // Source output position (right side of node)
+ const startX = sourcePos.x + sourceSize.width;
+ const startY = sourcePos.y + sourceSize.height / 2;
+
+ // Target input position
+ const endX = avgTargetX;
+ const endY = avgTargetY;
+
+ // Distribute reroutes evenly along the path
+ const count = chain.nodes.length;
+ for (let i = 0; i < count; i++) {
+ const t = (i + 1) / (count + 1);
+ const rerouteId = chain.nodes[i];
+
+ // Linear interpolation
+ const x = startX + (endX - startX) * t;
+ const y = startY + (endY - startY) * t;
+
+ // Center the reroute (typical reroute size: 90x26)
+ const rerouteSize = sizes.get(rerouteId) ?? { width: 90, height: 26 };
+ positions.set(rerouteId, {
+ x: x - rerouteSize.width / 2,
+ y: y - rerouteSize.height / 2,
+ });
+ }
+ }
+}
+
+/**
+ * Get all reroute node IDs from chains (for filtering from main layout)
+ */
+export function getRerouteNodeIds(chains: RerouteChain[]): Set {
+ const ids = new Set();
+ for (const chain of chains) {
+ for (const nodeId of chain.nodes) {
+ ids.add(nodeId);
+ }
+ }
+ return ids;
+}
diff --git a/src/layout/types.ts b/src/layout/types.ts
new file mode 100644
index 0000000..39eb0b4
--- /dev/null
+++ b/src/layout/types.ts
@@ -0,0 +1,263 @@
+/**
+ * Minimal type definitions for ComfyUI/LiteGraph
+ * These match the runtime global objects, not the package exports
+ */
+
+/** Point as [x, y] */
+export type Point = [x: number, y: number];
+
+/** Size as [width, height] */
+export type Size = [width: number, height: number];
+
+/** Node input slot */
+export interface NodeInputSlot {
+ name: string;
+ type: string;
+ link: number | null;
+}
+
+/** Node output slot */
+export interface NodeOutputSlot {
+ name: string;
+ type: string;
+ links: number[] | null;
+}
+
+/** Node flags */
+export interface NodeFlags {
+ pinned?: boolean;
+ collapsed?: boolean;
+}
+
+/** LiteGraph Node */
+export interface LGraphNode {
+ id: number;
+ type: string;
+ title: string;
+ pos: Point;
+ size: Size;
+ inputs: NodeInputSlot[];
+ outputs: NodeOutputSlot[];
+ flags?: NodeFlags;
+ locked?: boolean;
+ order?: number;
+}
+
+/** LiteGraph Group */
+export interface LGraphGroup {
+ id: number;
+ title: string;
+ pos: Point;
+ size: Size;
+ _nodes?: LGraphNode[];
+ _children?: Set<{ id: number }>;
+ _bounding?: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ set?: (x: number, y: number, w: number, h: number) => void;
+ };
+ resizeTo?: (items: Iterable, padding?: number) => void;
+}
+
+/** LiteGraph Link */
+export interface LLink {
+ id: number;
+ origin_id: number;
+ origin_slot: number;
+ target_id: number;
+ target_slot: number;
+ type: string;
+}
+
+/** Subgraph I/O node (uses bounding array format) */
+export interface SubgraphIONode {
+ id: number;
+ bounding: [x: number, y: number, width: number, height: number];
+}
+
+/** LiteGraph Graph (or Subgraph with I/O nodes) */
+export interface LGraph {
+ _nodes: LGraphNode[];
+ _groups: LGraphGroup[];
+ /** Links can be Map, Record, or Array depending on context */
+ links: Map | Record | LLink[];
+ setDirtyCanvas?: (fg: boolean, bg: boolean) => void;
+ /** Subgraph input node (only on Subgraph, not root LGraph) */
+ inputNode?: SubgraphIONode;
+ /** Subgraph output node (only on Subgraph, not root LGraph) */
+ outputNode?: SubgraphIONode;
+}
+
+/** Layout node with computed metadata */
+export interface LayoutNode {
+ /** Reference to ComfyUI node */
+ node: LGraphNode;
+ /** Node ID */
+ id: number;
+ /** DAG depth (0 = sources/inputs) */
+ layer: number;
+ /** Vertical position within layer */
+ orderInLayer: number;
+ /** Node IDs this depends on (predecessors) */
+ predecessors: number[];
+ /** Node IDs that depend on this (successors) */
+ successors: number[];
+ /** Node width (or group width if representative) */
+ width: number;
+ /** Node height (or group height if representative) */
+ height: number;
+ /** Computed X coordinate */
+ x: number;
+ /** Computed Y coordinate */
+ y: number;
+ /** True if this node represents a group in the layout */
+ isGroupRepresentative?: boolean;
+ /** The group this node represents (if isGroupRepresentative) */
+ group?: LayoutGroup;
+ /** True if this is a subgraph I/O node (input or output boundary) */
+ isSubgraphIO?: boolean;
+ /** Reference to runtime I/O node for position updates (has pos/size) */
+ runtimeIONode?: { id: number; pos: [number, number]; size: [number, number] };
+}
+
+/** Layer = vertical column of nodes */
+export interface Layer {
+ /** Layer index (0 = leftmost) */
+ index: number;
+ /** Nodes in this layer */
+ nodes: LayoutNode[];
+ /** Computed X position for this layer */
+ x: number;
+ /** Max node width in this layer */
+ maxWidth: number;
+}
+
+/** Layout group tracking with hierarchy support */
+export interface LayoutGroup {
+ /** Reference to ComfyUI group */
+ group: LGraphGroup;
+ /** Node IDs directly contained (not in child groups) */
+ memberIds: Set;
+ /** Child groups (nested inside this group) */
+ childGroups: LayoutGroup[];
+ /** Parent group (group containing this one), null for top-level */
+ parentGroup: LayoutGroup | null;
+ /** Nesting depth (0 = top-level) */
+ depth: number;
+ /** Calculated width for the group */
+ width: number;
+ /** Calculated height for the group */
+ height: number;
+}
+
+/** Layout configuration */
+export interface LayoutConfig {
+ /** Horizontal gap between layers */
+ horizontalGap: number;
+ /** Vertical gap between nodes in same layer */
+ verticalGap: number;
+ /** Padding inside groups */
+ groupPadding: number;
+ /** Layout origin X */
+ startX: number;
+ /** Layout origin Y */
+ startY: number;
+ /** Max barycenter iterations */
+ maxIterations: number;
+ /** Max columns per layer (0=auto, 1=disabled/vertical stack, 2+=fixed) */
+ maxColumns: number;
+ /** Max row width before new row when maxColumns=0 */
+ maxRowWidth: number;
+ /** Collapse reroute chains into edge waypoints */
+ collapseReroutes: boolean;
+ /** Gap between disconnected zone and DAG */
+ disconnectedGap: number;
+}
+
+/** Default configuration */
+export const DEFAULT_CONFIG: LayoutConfig = {
+ horizontalGap: 100,
+ verticalGap: 40,
+ groupPadding: 30,
+ startX: 100,
+ startY: 100,
+ maxIterations: 24,
+ maxColumns: 0,
+ maxRowWidth: 800,
+ collapseReroutes: true,
+ disconnectedGap: 150,
+};
+
+/** Node classification result */
+export interface ClassifiedNodes {
+ /** Nodes with connections (participate in DAG) */
+ connected: Set;
+ /** Nodes without any connections */
+ disconnected: Set;
+ /** Reroute nodes (for special handling) */
+ reroutes: Set;
+}
+
+/** Reroute chain for collapse/restore */
+export interface RerouteChain {
+ /** Reroute node IDs in order */
+ nodes: number[];
+ /** Source node before chain */
+ sourceNode: number;
+ /** Source slot index */
+ sourceSlot: number;
+ /** Target nodes after chain */
+ targets: Array<{ node: number; slot: number }>;
+}
+
+/** Packed row within a layer (for bin packing) */
+export interface PackedRow {
+ /** Node IDs in this row */
+ nodes: number[];
+ /** Max node height in row */
+ height: number;
+ /** Vertical offset from layer start */
+ yOffset: number;
+ /** Total width of nodes + gaps in row */
+ width: number;
+}
+
+/** Graph layout state */
+export interface LayoutState {
+ /** All layout nodes by ID (includes group representatives) */
+ nodes: Map;
+ /** Layers of nodes */
+ layers: Layer[];
+ /** Groups */
+ groups: LayoutGroup[];
+ /** Config */
+ config: LayoutConfig;
+ /** Maps node ID to its group (if any) */
+ nodeToGroup?: Map;
+ /** Maps group to its representative node ID */
+ groupRepresentatives?: Map;
+ /** All original ComfyUI nodes by ID */
+ allNodes?: Map;
+ /** Groups organized by depth (index 0 = deepest) for bottom-up processing */
+ groupsByDepth?: LayoutGroup[][];
+ /** Disconnected node IDs (placed in separate zone) */
+ disconnectedNodes?: Set;
+ /** Collapsed reroute chains (restored after layout) */
+ rerouteChains?: RerouteChain[];
+ /** Packed rows per layer (for bin-packed layout) */
+ packedRows?: Map;
+}
+
+/** Layout result */
+export interface LayoutResult {
+ /** Number of nodes processed */
+ nodeCount: number;
+ /** Number of layers */
+ layerCount: number;
+ /** Number of groups resized */
+ groupCount: number;
+ /** Execution time in ms */
+ executionMs: number;
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ea343d3
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "allowSyntheticDefaultImports": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts", "src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..d147a55
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig } from "vite";
+import path from "node:path";
+
+// Plugin to insert the ComfyUI custom import at the beginning of the file
+const insertComfyUICustomImport = () => {
+ return {
+ name: "insert-comfyui-custom-import",
+ transform(code: string) {
+ return `import { app } from "/scripts/app.js";\n${code}`;
+ },
+ };
+};
+
+export default defineConfig(() => ({
+ plugins: [insertComfyUICustomImport()],
+ build: {
+ emptyOutDir: true,
+ rollupOptions: {
+ // Don't bundle ComfyUI scripts - they will be loaded from the ComfyUI server
+ external: ["/scripts/app.js", "/scripts/api.js"],
+ input: {
+ index: path.resolve(__dirname, "src/index.ts"),
+ },
+ output: {
+ // Output to the dist/example_ext directory
+ dir: "dist",
+ entryFileNames: "[name].js",
+ chunkFileNames: "[name]-[hash].js",
+ assetFileNames: "[name][extname]",
+ },
+ },
+ },
+}));