3 Commits
Author SHA1 Message Date
Andrés Zsögön 7e092c7777 Release 1.1.4 with opt-in fast conditioning mode 2026-10-03 22:48:17 -03:00
Andrés Zsögön 140b32bcfb Release 1.1.3 with faster regional conditioning
Deduplicate identical global negative conditioning across regional and global branches, cutting redundant UNet and ControlNet evaluations while preserving the existing positive area, global strength, and fallback behavior.

Add a regression test for the single-negative graph structure.
2026-10-03 22:10:09 -03:00
Andrés Zsögön d81a3a2701 Add issue cleanup and Manager icon 2026-08-19 08:59:04 -03:00
5 changed files with 377 additions and 27 deletions
@@ -0,0 +1,33 @@
name: Close inactive issues
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: read
jobs:
close-inactive-issues:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ github.token }}
days-before-issue-stale: 23
days-before-issue-close: 7
stale-issue-label: stale
stale-issue-message: >
This issue has had no activity for 23 days. It will be
automatically closed in 7 days unless there is new activity.
close-issue-message: >
This issue was automatically closed after 30 days without
activity. If the problem still exists, please reopen it or
create a new issue with updated information.
close-issue-reason: not_planned
exempt-issue-labels: pinned,security,confirmed-bug
days-before-pr-stale: -1
days-before-pr-close: -1
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" role="img" aria-labelledby="title desc">
<title id="title">LoRA Pipeline icon</title>
<desc id="desc">LP monogram in violet and yellow.</desc>
<defs>
<linearGradient id="panel" x1="42" y1="32" x2="360" y2="370" gradientUnits="userSpaceOnUse">
<stop stop-color="#292632"/>
<stop offset="1" stop-color="#17171d"/>
</linearGradient>
<linearGradient id="accent" x1="82" y1="94" x2="318" y2="304" gradientUnits="userSpaceOnUse">
<stop stop-color="#b49aff"/>
<stop offset="1" stop-color="#ffd400"/>
</linearGradient>
</defs>
<rect width="400" height="400" rx="88" fill="#11131a"/>
<rect x="28" y="28" width="344" height="344" rx="72" fill="url(#panel)" stroke="url(#accent)" stroke-width="10"/>
<text x="200" y="252" fill="url(#accent)" font-family="Arial, Helvetica, sans-serif" font-size="150" font-weight="800" letter-spacing="-8" text-anchor="middle">LP</text>
<circle cx="86" cy="314" r="9" fill="#b49aff"/>
<circle cx="116" cy="314" r="9" fill="#ffd400"/>
<path d="M146 314h168" stroke="url(#accent)" stroke-width="10" stroke-linecap="round" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+113 -25
View File
@@ -1,3 +1,5 @@
import math
import torch
@@ -78,6 +80,17 @@ class ConditioningPipelineCombine:
},
"optional": {
"global_strength": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 10.0, "step": 0.05}),
"fast_mode": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Faster full-canvas mode. Concatenates the global positive "
"into every regional positive and avoids a separate global "
"UNet pass. This changes image generation semantics."
),
},
),
},
}
@@ -100,7 +113,45 @@ class ConditioningPipelineCombine:
mask[y_px : y_px + h_px, x_px : x_px + w_px] = 1.0
return mask.unsqueeze(0)
def run(self, global_positive, global_negative, pipeline, global_strength=0.3):
@staticmethod
def _make_fast_mask(x, y, width, height, res):
mask = torch.zeros(res, res)
x_start = max(0, min(res, math.floor(x * res)))
y_start = max(0, min(res, math.floor(y * res)))
x_end = max(x_start, min(res, math.ceil((x + width) * res)))
y_end = max(y_start, min(res, math.ceil((y + height) * res)))
mask[y_start:y_end, x_start:x_end] = 1.0
return mask.unsqueeze(0)
@staticmethod
def _concat_global(conditioning, global_positive, global_strength):
if global_strength <= 0.0 or not conditioning or not global_positive:
return conditioning
global_tokens = global_positive[0][0] * global_strength
combined = []
for entry in conditioning:
tokens = torch.cat((entry[0], global_tokens), dim=1)
combined.append([tokens, entry[1].copy()])
return combined
@staticmethod
def _masks_cover_canvas(masks):
if not masks:
return False
coverage = torch.zeros_like(masks[0], dtype=torch.bool)
for mask in masks:
coverage |= mask > 0
return bool(torch.all(coverage).item())
def run(
self,
global_positive,
global_negative,
pipeline,
global_strength=0.3,
fast_mode=False,
):
from comfy_execution.graph_utils import GraphBuilder
global_strength = round(float(global_strength), 2)
@@ -130,66 +181,103 @@ class ConditioningPipelineCombine:
graph = GraphBuilder()
# First entry: PairConditioningSetProperties (no previous combined state yet)
if fast_mode:
masks = []
acc_pos = None
for item in valid:
mask = self._make_fast_mask(
item["x"], item["y"], item["width"], item["height"], self.BASE_RES,
)
masks.append(mask)
regional_positive = self._concat_global(
item["conditioning"], global_positive, global_strength,
)
if acc_pos is None:
acc = graph.node(
"ConditioningSetProperties",
cond_NEW=regional_positive,
strength=item.get("strength", 1.0),
set_cond_area="default",
mask=mask,
)
else:
acc = graph.node(
"ConditioningSetPropertiesAndCombine",
cond=acc_pos,
cond_NEW=regional_positive,
strength=item.get("strength", 1.0),
set_cond_area="default",
mask=mask,
)
acc_pos = acc.out(0)
if not self._masks_cover_canvas(masks):
fallback = graph.node(
"ConditioningSetDefaultCombine",
cond=acc_pos,
cond_DEFAULT=global_positive,
)
acc_pos = fallback.out(0)
return {
"result": (acc_pos, global_negative, areas_list),
"expand": graph.finalize(),
}
# First entry: set properties on the regional positive conditioning.
# The negative conditioning is global and identical for every region, so
# evaluating masked copies of it would repeat the same UNet prediction.
first = valid[0]
mask_0 = self._make_mask(
first["x"], first["y"], first["width"], first["height"], self.BASE_RES,
)
acc = graph.node(
"PairConditioningSetProperties",
positive_NEW=first["conditioning"],
negative_NEW=global_negative,
"ConditioningSetProperties",
cond_NEW=first["conditioning"],
strength=first.get("strength", 1.0),
set_cond_area="default",
mask=mask_0,
)
acc_pos = acc.out(0)
acc_neg = acc.out(1)
# Entries siguientes: PairConditioningSetPropertiesAndCombine
# Following entries: combine regional positive conditionings only.
for item in valid[1:]:
mask_i = self._make_mask(
item["x"], item["y"], item["width"], item["height"], self.BASE_RES,
)
acc = graph.node(
"PairConditioningSetPropertiesAndCombine",
positive=acc_pos,
negative=acc_neg,
positive_NEW=item["conditioning"],
negative_NEW=global_negative,
"ConditioningSetPropertiesAndCombine",
cond=acc_pos,
cond_NEW=item["conditioning"],
strength=item.get("strength", 1.0),
set_cond_area="default",
mask=mask_i,
)
acc_pos = acc.out(0)
acc_neg = acc.out(1)
# Global como entrada full-image con strength controlable
if global_strength > 0.0:
global_mask = self._make_mask(0.0, 0.0, 1.0, 1.0, self.BASE_RES)
acc = graph.node(
"PairConditioningSetPropertiesAndCombine",
positive=acc_pos,
negative=acc_neg,
positive_NEW=global_positive,
negative_NEW=global_negative,
"ConditioningSetPropertiesAndCombine",
cond=acc_pos,
cond_NEW=global_positive,
strength=global_strength,
set_cond_area="default",
mask=global_mask,
)
acc_pos = acc.out(0)
acc_neg = acc.out(1)
# Default fallback (safety net para pixeles sin cubrir)
final = graph.node(
"PairConditioningSetDefaultCombine",
positive=acc_pos,
negative=acc_neg,
positive_DEFAULT=global_positive,
negative_DEFAULT=global_negative,
"ConditioningSetDefaultCombine",
cond=acc_pos,
cond_DEFAULT=global_positive,
)
return {
"result": (final.out(0), final.out(1), areas_list),
"result": (final.out(0), global_negative, areas_list),
"expand": graph.finalize(),
}
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "comfyui-lora-pipeline"
version = "1.1.2"
version = "1.1.4"
description = "Area-based LoRA scheduling and area conditioning wrappers for ComfyUI."
readme = "README.md"
requires-python = ">=3.10"
@@ -19,5 +19,5 @@ Documentation = "https://github.com/andreszs/comfyui-lora-pipeline#readme"
[tool.comfy]
PublisherId = "andreszs"
DisplayName = "LoRA Pipeline"
Icon = ""
Icon = "https://raw.githubusercontent.com/andreszs/comfyui-lora-pipeline/main/assets/icon.svg"
Banner = "https://raw.githubusercontent.com/andreszs/comfyui-lora-pipeline/refs/heads/main/assets/banner_112.gif"
+209
View File
@@ -0,0 +1,209 @@
import importlib.util
import sys
import types
import unittest
from pathlib import Path
import torch
_MODULE_PATH = Path(__file__).parents[1] / "nodes" / "cond_area_pipeline.py"
_SPEC = importlib.util.spec_from_file_location("lora_pipeline_cond_area", _MODULE_PATH)
_MODULE = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(_MODULE)
ConditioningPipelineCombine = _MODULE.ConditioningPipelineCombine
class _Output:
def __init__(self, node_type, index):
self.node_type = node_type
self.index = index
class _Node:
def __init__(self, node_type):
self.node_type = node_type
def out(self, index):
return _Output(self.node_type, index)
class _GraphBuilder:
instance = None
def __init__(self):
self.calls = []
_GraphBuilder.instance = self
def node(self, node_type, **inputs):
self.calls.append((node_type, inputs))
return _Node(node_type)
def finalize(self):
return self.calls
class ConditioningPipelineCombineTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
graph_utils = types.ModuleType("comfy_execution.graph_utils")
graph_utils.GraphBuilder = _GraphBuilder
comfy_execution = types.ModuleType("comfy_execution")
comfy_execution.graph_utils = graph_utils
sys.modules["comfy_execution"] = comfy_execution
sys.modules["comfy_execution.graph_utils"] = graph_utils
def test_negative_conditioning_is_not_duplicated_per_area(self):
global_positive = object()
global_negative = object()
pipeline = [
{
"conditioning": object(),
"x": 0.0,
"y": 0.0,
"width": 0.5,
"height": 1.0,
"strength": 0.8,
},
{
"conditioning": object(),
"x": 0.5,
"y": 0.0,
"width": 0.5,
"height": 1.0,
"strength": 0.8,
},
]
result = ConditioningPipelineCombine().run(
global_positive,
global_negative,
pipeline,
global_strength=0.3,
)
positive, negative, areas = result["result"]
calls = _GraphBuilder.instance.calls
self.assertIs(negative, global_negative)
self.assertEqual(len(areas), 2)
self.assertEqual(
[node_type for node_type, _ in calls],
[
"ConditioningSetProperties",
"ConditioningSetPropertiesAndCombine",
"ConditioningSetPropertiesAndCombine",
"ConditioningSetDefaultCombine",
],
)
self.assertEqual(positive.node_type, "ConditioningSetDefaultCombine")
self.assertFalse(any(node_type.startswith("PairConditioning") for node_type, _ in calls))
def test_fast_mode_concatenates_global_positive_and_skips_full_coverage_fallback(self):
global_positive = [[torch.full((1, 1, 2), 2.0), {}]]
global_negative = object()
pipeline = [
{
"conditioning": [[torch.full((1, 1, 2), 1.0), {}]],
"x": 0.0,
"y": 0.0,
"width": 0.5,
"height": 1.0,
"strength": 0.8,
},
{
"conditioning": [[torch.full((1, 1, 2), 3.0), {}]],
"x": 0.5,
"y": 0.0,
"width": 0.5,
"height": 1.0,
"strength": 0.8,
},
]
result = ConditioningPipelineCombine().run(
global_positive,
global_negative,
pipeline,
global_strength=0.25,
fast_mode=True,
)
positive, negative, _ = result["result"]
calls = _GraphBuilder.instance.calls
first_tokens = calls[0][1]["cond_NEW"][0][0]
self.assertIs(negative, global_negative)
self.assertEqual(
[node_type for node_type, _ in calls],
[
"ConditioningSetProperties",
"ConditioningSetPropertiesAndCombine",
],
)
self.assertEqual(positive.node_type, "ConditioningSetPropertiesAndCombine")
self.assertEqual(first_tokens.shape[1], 2)
torch.testing.assert_close(first_tokens[:, 1:, :], torch.full((1, 1, 2), 0.5))
def test_fast_mode_keeps_global_fallback_for_incomplete_coverage(self):
global_positive = [[torch.ones((1, 1, 2)), {}]]
pipeline = [
{
"conditioning": [[torch.ones((1, 1, 2)), {}]],
"x": 0.0,
"y": 0.0,
"width": 0.5,
"height": 1.0,
"strength": 1.0,
},
]
result = ConditioningPipelineCombine().run(
global_positive,
object(),
pipeline,
fast_mode=True,
)
positive = result["result"][0]
calls = _GraphBuilder.instance.calls
self.assertEqual(calls[-1][0], "ConditioningSetDefaultCombine")
self.assertEqual(positive.node_type, "ConditioningSetDefaultCombine")
def test_fast_mode_overlapping_fractional_regions_cover_canvas(self):
conditioning = [[torch.ones((1, 1, 2)), {}]]
pipeline = [
{
"conditioning": conditioning,
"x": 0.0,
"y": 0.0,
"width": 0.52,
"height": 1.0,
"strength": 1.0,
},
{
"conditioning": conditioning,
"x": 0.48,
"y": 0.0,
"width": 0.52,
"height": 1.0,
"strength": 1.0,
},
]
ConditioningPipelineCombine().run(
conditioning,
object(),
pipeline,
fast_mode=True,
)
calls = _GraphBuilder.instance.calls
self.assertFalse(
any(node_type == "ConditioningSetDefaultCombine" for node_type, _ in calls),
)
if __name__ == "__main__":
unittest.main()