Initial release
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -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.
|
||||
+21
@@ -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.
|
||||
@@ -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.
|
||||
+57
@@ -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"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
# No extra Python dependencies are required for this node.
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user