commit 6c9f21b24d2cf61a0ecaafeb25cefc5bd1d39c6c Author: Szymon <56864476+esp-dev@users.noreply.github.com> Date: Thu Apr 30 15:00:27 2026 +0200 Initial release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec5a33d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd + +.venv/ +venv/ + +.vscode/ +.idea/ + +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb6d21a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 1.0.0 + +- Initial release. +- Added `LoraRangeLoaderModelOnly`. +- Added `first`, `last`, `current`, and `mode` controls. +- Added frontend logic for `fixed`, `increment`, `decrement`, and `random` current switching. +- Added Comfy Registry metadata and documentation. \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..cdaf6ff --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 szymonj + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bc539d --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# comfyui-lora-range-loader (ComfyUI) + +Custom node for ComfyUI that loads a LoRA like the core `LoraLoaderModelOnly`, but adds a filename range sweep workflow for testing many LoRA checkpoints from the same training run. + +## What this node does + +- Adds the node **Load LoRA Range (Model Only)**. +- Loads the LoRA selected in `current` exactly like the core model-only LoRA loader. +- Adds `first` and `last` fields to define an allowed filename range. +- Adds `mode` to control how `current` changes after each queued run. +- Supports these modes: + - `fixed`: keep `current` unchanged + - `increment`: move to the next filename in the sorted `first..last` range and wrap to `first` + - `decrement`: move backward in the sorted `first..last` range and wrap to `last` + - `random`: pick a random filename from the sorted `first..last` range + +## How it works + +The node is split into two parts: + +- Backend Python loads only the LoRA selected in `current`. +- Frontend JavaScript updates `current` after the workflow is queued. + +This means the LoRA used for the current execution is always the value visible in `current` at queue time, and the next value is prepared for the following run. + +## Range rules + +- The list of LoRAs is sorted by filename in ascending order. +- `first` must come before or equal to `last` in that sorted list. +- If `first` comes after `last`, automatic switching is disabled. +- If `current` is outside the `first..last` range, the node still loads that LoRA, but `increment`, `decrement`, and `random` do not change it. + +## LoRA loading behavior + +- The node reuses the core `LoraLoader` implementation. +- It does not load `first` or `last` into memory. +- It only loads `current`. +- It inherits the same single-entry cache as the core loader, so repeating the same `current` value does not reload the LoRA file unnecessarily. + +## Compatibility + +Backend compatibility: + +- The Python node is lightweight and relies only on ComfyUI core APIs. +- No extra Python packages are required. + +Frontend compatibility: + +- Full automatic switching of `current` requires a modern ComfyUI frontend that supports widget queue lifecycle hooks. +- If the frontend extension does not run, the backend node still works as a manual `current` LoRA loader. + +## How to use + +1. Add **Load LoRA Range (Model Only)** to the graph. +2. Connect your `MODEL` input. +3. Choose `first`, `last`, and `current` from the LoRA dropdowns. +4. Choose `mode`. +5. Run the workflow repeatedly. +6. Observe `current` advancing for the next run according to `mode`. + +## Install via ComfyUI-Manager + +1. Open **Manager** in ComfyUI. +2. Install this node from the list or by repository URL. +3. Restart ComfyUI or refresh node definitions. + +## Manual install + +1. Clone or copy this repository into `ComfyUI/custom_nodes/`. +2. Restart ComfyUI. + +No additional Python dependencies are needed. + +## Included files + +- [__init__.py](__init__.py) - backend node definition +- [web/lora_range_loader.js](web/lora_range_loader.js) - frontend logic that advances `current` + +## Notes for publishing + +- The Registry package name in `pyproject.toml` is intended to be stable and should be treated as immutable after publishing. +- If you want a different final GitHub repository URL or package name, change it before the first Registry publish. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..84d43ff --- /dev/null +++ b/__init__.py @@ -0,0 +1,57 @@ +import folder_paths + +from nodes import LoraLoader + + +class LoraRangeLoaderModelOnly(LoraLoader): + @classmethod + def INPUT_TYPES(cls): + loras = folder_paths.get_filename_list("loras") + return { + "required": { + "model": ("MODEL", {"tooltip": "The diffusion model the LoRA will be applied to."}), + "first": (loras, {"tooltip": "First LoRA in the allowed filename range."}), + "last": (loras, {"tooltip": "Last LoRA in the allowed filename range."}), + "current": (loras, {"tooltip": "The LoRA that will be loaded for the current run."}), + "mode": ( + ["fixed", "increment", "decrement", "random"], + {"default": "fixed", "tooltip": "How current should change after a run is queued."}, + ), + "strength_model": ( + "FLOAT", + { + "default": 1.0, + "min": -100.0, + "max": 100.0, + "step": 0.01, + "tooltip": "How strongly to modify the diffusion model. This value can be negative.", + }, + ), + } + } + + RETURN_TYPES = ("MODEL",) + OUTPUT_TOOLTIPS = ("The modified diffusion model.",) + FUNCTION = "load_lora_model_only" + CATEGORY = "loaders" + DESCRIPTION = "Loads the current LoRA like Load LoRA (Model Only) and lets the frontend advance through a filename range after each queued run." + SEARCH_ALIASES = ["lora range", "lora sweep", "load lora range", "checkpoint training lora"] + + def load_lora_model_only(self, model, first, last, current, mode, strength_model): + del first + del last + del mode + return (self.load_lora(model, None, current, strength_model, 0)[0],) + + +NODE_CLASS_MAPPINGS = { + "LoraRangeLoaderModelOnly": LoraRangeLoaderModelOnly, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "LoraRangeLoaderModelOnly": "Load LoRA Range (Model Only)", +} + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] \ No newline at end of file diff --git a/comfyui-lora-range-loader-icon-256.png b/comfyui-lora-range-loader-icon-256.png new file mode 100644 index 0000000..b6a01ac Binary files /dev/null and b/comfyui-lora-range-loader-icon-256.png differ diff --git a/comfyui-lora-range-loader-icon-512.png b/comfyui-lora-range-loader-icon-512.png new file mode 100644 index 0000000..6fb0232 Binary files /dev/null and b/comfyui-lora-range-loader-icon-512.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f49f0f3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "comfyui-lora-range-loader" +description = "ComfyUI custom node: load a LoRA from current selection and automatically advance current within a first/last filename range." +version = "1.0.0" +license = { file = "LICENSE.txt" } +dependencies = [] + +[project.urls] +Repository = "https://github.com/esp-dev/comfyui-lora-range-loader" + +[tool.comfy] +PublisherId = "szymonj" +DisplayName = "comfyui-lora-range-loader" +Icon = "https://raw.githubusercontent.com/esp-dev/comfyui-lora-range-loader/main/comfyui-lora-range-loader-icon-256.png" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8fe8285 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# No extra Python dependencies are required for this node. \ No newline at end of file diff --git a/web/lora_range_loader.js b/web/lora_range_loader.js new file mode 100644 index 0000000..18f3062 --- /dev/null +++ b/web/lora_range_loader.js @@ -0,0 +1,109 @@ +import { app } from "../../scripts/app.js"; + + +const NODE_CLASS = "LoraRangeLoaderModelOnly"; + + +function getWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name); +} + + +function getSortedLoras(currentWidget) { + const values = Array.isArray(currentWidget?.options?.values) ? [...currentWidget.options.values] : []; + values.sort((left, right) => String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" })); + return values; +} + + +function getAllowedRange(node) { + const firstWidget = getWidget(node, "first"); + const lastWidget = getWidget(node, "last"); + const currentWidget = getWidget(node, "current"); + if (!firstWidget || !lastWidget || !currentWidget) { + return null; + } + + const sorted = getSortedLoras(currentWidget); + const firstIndex = sorted.indexOf(firstWidget.value); + const lastIndex = sorted.indexOf(lastWidget.value); + if (firstIndex === -1 || lastIndex === -1 || firstIndex > lastIndex) { + return null; + } + + return sorted.slice(firstIndex, lastIndex + 1); +} + + +function advanceCurrent(node) { + const currentWidget = getWidget(node, "current"); + const modeWidget = getWidget(node, "mode"); + if (!currentWidget || !modeWidget) { + return; + } + + const allowedRange = getAllowedRange(node); + if (!allowedRange || allowedRange.length === 0) { + return; + } + + const currentIndex = allowedRange.indexOf(currentWidget.value); + if (currentIndex === -1) { + return; + } + + let nextValue = currentWidget.value; + switch (modeWidget.value) { + case "fixed": + return; + case "increment": + nextValue = allowedRange[(currentIndex + 1) % allowedRange.length]; + break; + case "decrement": + nextValue = allowedRange[(currentIndex - 1 + allowedRange.length) % allowedRange.length]; + break; + case "random": + nextValue = allowedRange[Math.floor(Math.random() * allowedRange.length)]; + break; + default: + return; + } + + currentWidget.value = nextValue; + currentWidget.callback?.(nextValue); + node.setDirtyCanvas(true, true); +} + + +function installAfterQueuedHandler(node) { + if (node.__loraRangeAfterQueuedInstalled) { + return; + } + + const currentWidget = getWidget(node, "current"); + if (!currentWidget) { + return; + } + + const originalAfterQueued = currentWidget.afterQueued?.bind(currentWidget); + currentWidget.afterQueued = function afterQueued(event = {}) { + originalAfterQueued?.(event); + if (!event.isPartialExecution) { + advanceCurrent(node); + } + }; + + node.__loraRangeAfterQueuedInstalled = true; +} + + +app.registerExtension({ + name: "Comfy.LoraRangeLoader", + nodeCreated(node) { + if (node.constructor.comfyClass !== NODE_CLASS) { + return; + } + + installAfterQueuedHandler(node); + }, +}); \ No newline at end of file