diff --git a/README.md b/README.md index e0e5091..4f5a37f 100644 --- a/README.md +++ b/README.md @@ -669,8 +669,11 @@ Each LoRA card includes: - **Folder (Separate):** Test each LoRA in the folder individually with the same strength settings. - **Folder (Combined):** Stack ALL LoRAs in the folder into a single load. * **Model Strength Slider:** Range 0.0–2.0 (default 1.0). Controls how strongly the LoRA affects the model. Number input accepts values beyond slider range. -* **CLIP Strength Slider:** Range 0.0–2.0 (default 1.0). Controls how strongly the LoRA affects text encoding. Independent from model strength. -* **🔗 Lock Strengths:** Toggle to link model and CLIP strengths so both sliders move together. +* **CLIP Strength Slider:** Shown when the lock is off. Range 0.0–2.0 (default 1.0). Controls how strongly the LoRA affects text encoding. Independent from model strength. +* **+ Add Strength:** The + button next to the Model Strength box adds another strength to compare, as a new row right below (up to 8 strengths per LoRA, the base sliders count as the first). With **Lock Model & CLIP Strength Together** on (the default) each row is one *Strength* slider used for both model and CLIP; with the lock off each row has its own *Model* and *CLIP* sliders and is one job (pair). ✕ removes a row. A row equal to the base or to an earlier row is greyed out ("duplicate, ignored") and skipped. Rows belong to the LoRA name, so two cards with the same LoRA share them. +* **Job count:** every strength is one full copy of the config's jobs, differing only in the LoRA strength, so the count multiplies (a config with 3 strengths and 2 samplers is 6 jobs). With several LoRAs in one config they are stacked together and you get every combination, first LoRA slowest: a LoRA with 2 strengths plus a LoRA with 3 strengths is 6 stacked entries. The Iterations counter and the preview JSON both show it. Folder entries are never stacked; their rows become separate entries. +* **🔗 Lock Strengths:** Toggle to link model and CLIP strengths so both sliders move together. Turning it on also sets CLIP = model on every extra row. +* **Old "Compare Strengths" box:** Workflows saved with the old comma-separated Compare Strengths box still load and run unchanged; their values show up as + rows and are converted the first time you edit that LoRA. * **🔍 Lookup Trigger Words:** Opens the Trigger Word Lookup Modal (fetches from CivitAI API). * **🚫 Bypass Trigger Fetch:** Toggle to prevent automatic trigger word lookup for this LoRA. * **🗑️ Remove LoRA:** Deletes this LoRA from the config (at least one is always required). @@ -1160,7 +1163,7 @@ Tests all models from all three folders with the same settings. ### LoRA Strength Sweep -Find the optimal strength for a new LoRA using the Config Builder: add the same LoRA 6 times with strengths from 0.4 to 1.5, keep everything else minimal (1 sampler, 1 scheduler), and compare the 6 images side by side. +Find the optimal strength for a new LoRA using the Config Builder: add the LoRA once, set the base strength to 0.4, then press **+** five times and set the new rows to 0.6, 0.8, 1.0, 1.2 and 1.5 (6 strengths in total, 8 at most). Keep everything else minimal (1 sampler, 1 scheduler) and compare the 6 images side by side. With the lock off each row also has its own CLIP value, so you can sweep model and CLIP together as pairs. In the Dashboard the label overlay shows each image's strength (for example `detail:0.60:0.60`) even with "unique only" on, because the strength differs between cards. ### Quality Enhancement Stack diff --git a/Roadmap.md b/Roadmap.md index fd49f7a..4d036db 100644 --- a/Roadmap.md +++ b/Roadmap.md @@ -95,7 +95,7 @@ ProjectStructure.md has notes to assist AI in developing this project -#### **12. Arrays in LoRA Weights** Integrated but needs adding to the Builder UI +#### ~~**12. Arrays in LoRA Weights**~~ DONE — the + button in the Builder's LoRA card adds strength rows (one slider, or Model + CLIP sliders with the lock off; max 8 per LoRA); each row is one full copy of the config's jobs, stacked LoRAs get every combination. State key `lora_extra_strengths`; the old Compare Strengths box is converted on first edit. Builder UI could have a + button right next to model strength text input (on its right side) to make it easy to add a compare different lora strengths. If activated it should add an additonal slider, or two sliders if 🔒 Lock Model & CLIP Strength Together is deactivated, for each time they click +. If a lora has a multiple strength array, the preview json and py node json_output should duplicate the full config for that run for each entry with the only difference being each lora strength. If multiple loras have multiple arrays it needs to output a cartesian of an each for each of all lora strenth combos. diff --git a/config_builder_node.py b/config_builder_node.py index be4ada8..6cd3797 100644 --- a/config_builder_node.py +++ b/config_builder_node.py @@ -14,6 +14,8 @@ from typing import List, Dict, Any import server from aiohttp import web import hashlib +import itertools +import math from .civitai import civitai_fetch_by_hash from . import image_metrics @@ -475,6 +477,76 @@ class UltimateConfigBuilder: print(f"[ConfigBuilder] Warning: Could not parse '{item}'") return result + @staticmethod + def _base_strengths(lora_str: str): + """Return (model, clip) floats for a "name[:m[:c]]" lora string, mirroring + parse_lora_definition (no strengths = 1.0/1.0, missing CLIP = model). + Returns None for bracket-array strings or anything unparseable.""" + if "[" in lora_str: + return None + parts = lora_str.split(":") + try: + m = float(parts[1]) if len(parts) > 1 and parts[1].strip() != "" else 1.0 + c = float(parts[2]) if len(parts) > 2 and parts[2].strip() != "" else m + except ValueError: + return None + if not (math.isfinite(m) and math.isfinite(c)): + return None + return (m, c) + + @staticmethod + def _clean_extra_strengths(raw, locked, base_m, base_c, max_total=8) -> List[tuple]: + """Validate the extra strength rows of one LoRA (state key lora_extra_strengths). + + raw: list of {"model": m, "clip": c} dicts (or leniently [m, c] lists); a missing + clip means "same as model". locked: clip is forced to model. base_m/base_c: the + base row (None = unknown, no dedupe against it). Returns the effective extra + (model, clip) tuples rounded to 2 decimals: invalid rows dropped, at most + max_total - 1 rows kept, duplicates of the base or of an earlier row dropped. + SYNC: JS twin is normalizeExtraStrengths() in conf-builder-utilities.js.""" + if not isinstance(raw, (list, tuple)): + return [] + + def _num(v): + if isinstance(v, bool) or not isinstance(v, (int, float, str)): + return None + try: + f = float(v) + except ValueError: + return None + return f if math.isfinite(f) else None + + rows = [] + for item in raw: + if isinstance(item, dict): + m_raw, c_raw = item.get("model"), item.get("clip") + elif isinstance(item, (list, tuple)) and len(item) >= 1: + m_raw, c_raw = item[0], (item[1] if len(item) > 1 else None) + else: + continue + m = _num(m_raw) + if m is None: + continue + c = _num(c_raw) if c_raw is not None else m + if c is None: + continue + if locked: + c = m + rows.append((round(m, 2) + 0.0, round(c, 2) + 0.0)) + if len(rows) > max_total - 1: + print(f"[ConfigBuilder] Warning: {len(rows)} extra strengths for one LoRA, keeping the first {max_total - 1}") + rows = rows[:max_total - 1] + seen = set() + if base_m is not None and base_c is not None: + seen.add((round(base_m, 2) + 0.0, round(base_c, 2) + 0.0)) + out = [] + for row in rows: + if row in seen: + continue + seen.add(row) + out.append(row) + return out + @staticmethod def process_lora_array(config_array: Dict, include_none: bool) -> List[str]: """ @@ -514,7 +586,8 @@ class UltimateConfigBuilder: # (config_array.lora_weight_arrays[name + "_model" / "_clip"]). Rewrite # each lora string from "name:m:c" -> "name:[m_arr]:[c_arr|c]" so the # orchestrator's _expand_lora_weight_arrays can fan them out at runtime. - # SYNC WARNING: must match convertStateToConfigs() in conf-builder-utilities.js. + # (Preview and run both use this Python path; the only JS twin is the Iterations + # count, loraEntryCount() in conf-builder-utilities.js.) weight_arrays = config_array.get("lora_weight_arrays", {}) or {} def _apply_weight_array(lora_str: str) -> str: @@ -543,7 +616,31 @@ class UltimateConfigBuilder: return f"{name}:{model_str}:[" + ", ".join(str(v) for v in clip_arr) + "]" return lora_str - lora_strings = [_apply_weight_array(s) for s in lora_strings] + # Strength rows added with the Builder's "+" button (state key + # lora_extra_strengths[name] = [{model, clip}, ...]). Each LoRA string becomes a + # list of concrete variants: the user's verbatim base string first, then one + # "name:m:c" per extra row. A non-empty entry replaces lora_weight_arrays for that + # name. SYNC: loraEntryCount() in conf-builder-utilities.js mirrors this. + extra_map = config_array.get("lora_extra_strengths", {}) or {} + lock_map = config_array.get("lora_strength_lock", {}) or {} + if not isinstance(extra_map, dict): + extra_map = {} + if not isinstance(lock_map, dict): + lock_map = {} + + def _variants(lora_str: str) -> List[str]: + name = lora_str.split(":")[0] + raw_rows = extra_map.get(name) + if raw_rows: + base = UltimateConfigBuilder._base_strengths(lora_str) + base_m, base_c = base if base else (None, None) + locked = lock_map.get(name) is not False + extras = UltimateConfigBuilder._clean_extra_strengths(raw_rows, locked, base_m, base_c) + if extras: + return [lora_str] + [f"{name}:{m:.2f}:{c:.2f}" for m, c in extras] + return [_apply_weight_array(lora_str)] + + variants = [_variants(s) for s in lora_strings] # Add combined version if requested if combine and len(lora_strings) > 1: @@ -551,12 +648,21 @@ class UltimateConfigBuilder: # runtime into multiple loras. Detect by the NAME portion (before the # first colon) ending in "/", not the whole string, so that # "folder/:[a,b]:[c,d]" (folder ref + strength array) is also excluded. - stackable = [s for s in lora_strings if not s.split(":", 1)[0].endswith("/")] + stackable = [v for s, v in zip(lora_strings, variants) if not s.split(":", 1)[0].endswith("/")] if len(stackable) > 1: - # When combine is true, ONLY return the combined version - combined = " + ".join(stackable) - lora_strings = [combined] + # When combine is true, ONLY return the combined versions: every + # combination of the stacked LoRAs' strengths, first LoRA slowest. + combos = 1 + for v in stackable: + combos *= len(v) + if combos > 256: + print(f"[ConfigBuilder] Warning: {array_name}: LoRA strengths produce {combos} stacked combinations") + lora_strings = [" + ".join(p) for p in itertools.product(*stackable)] print(f"[ConfigBuilder] {array_name}: Combined {len(stackable)} LoRAs into stack") + else: + lora_strings = [x for v in variants for x in v] + else: + lora_strings = [x for v in variants for x in v] # Remove duplicates while preserving order seen = set() diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 1fe0595..5fecee5 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -63,9 +63,12 @@ function computeLabelGlobalValues() { labelGlobalValues[f] = valueSets[f].size === 1; }); - // For lora unique-only: also track which individual loras appear on every card + // For lora unique-only: also track which individual loras appear on every card. + // A name is only "global" when it is on every card AND with a single strength string: + // a strength sweep of one LoRA (x:1.00:1.00 vs x:0.60:0.60) must keep its tag. if (labelMode.fields.loraUniqueOnly) { const loraCountMap = {}; + const loraPartMap = {}; let totalCards = activeData.length; for (const item of activeData) { const loraStr = item.lora || "None"; @@ -78,11 +81,13 @@ function computeLabelGlobalValues() { seen.add(name); loraCountMap[name] = (loraCountMap[name] || 0) + 1; } + if (!loraPartMap[name]) loraPartMap[name] = new Set(); + loraPartMap[name].add(part.trim()); } } labelGlobalValues._loraGlobalNames = new Set(); for (const [name, count] of Object.entries(loraCountMap)) { - if (count === totalCards) { + if (count === totalCards && loraPartMap[name].size === 1) { labelGlobalValues._loraGlobalNames.add(name); } } @@ -669,7 +674,9 @@ function initFilters() { fullText = val.replace(/ \+ /g, '\n'); } else { let clean = val.replace(/\\/g, '/').split('/').pop().split(':')[0]; - label = truncateText(clean, 12); + // Keep the strengths so the buttons of a strength sweep differ (detail:0.60:0.60) + const strengths = val.includes(':') ? val.slice(val.indexOf(':')) : ''; + label = truncateText(clean, 12) + strengths; fullText = val; } } else if (key === 'model') { diff --git a/tests/e2e/test_builder_lora_strengths.py b/tests/e2e/test_builder_lora_strengths.py new file mode 100644 index 0000000..980db70 --- /dev/null +++ b/tests/e2e/test_builder_lora_strengths.py @@ -0,0 +1,307 @@ +"""Roadmap item 12 in a real browser: the Builder's "+" LoRA strength rows, plus the Dashboard label. + +The Builder UI modules (web/conf_builder/*.js) are loaded as ES modules from the fake origin. Only +conf-builder-main.js imports ComfyUI's frontend; conf-builder-config-management.js and the modules it +imports only import each other, so a tiny stub node can drive createLoraElement() directly. The preview +panel's POST /configbuilder/preview is answered by the REAL UltimateConfigBuilder.state_to_configs_json +running in the test process, so what the page shows is what Python would emit. +""" +import json +import os + +import pytest + +if os.environ.get("USCG_REQUIRE_E2E") == "1": + import playwright.sync_api # noqa: F401 +else: + pytest.importorskip("playwright.sync_api") + +from playwright.sync_api import expect # noqa: E402 + +from config_builder_node import UltimateConfigBuilder # noqa: E402 +from tests.test_state_to_configs_json import make_state # noqa: E402 + +pytestmark = pytest.mark.e2e + +ORIGIN = "http://uscg.test" +_MOD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "web", "conf_builder") + +PAGE = """ +builder harness + +
+""" + + +class Builder: + def __init__(self, page): + self.page = page + self.pageerrors = [] + self.preview_posts = 0 + + def handle(self, route): + req = route.request + if not req.url.startswith(ORIGIN + "/"): + return route.abort() + path = req.url[len(ORIGIN):].partition("?")[0] + if path == "/builder.html": + return route.fulfill(status=200, content_type="text/html; charset=utf-8", body=PAGE) + if path.startswith("/conf_builder/") and path.endswith(".js"): + fp = os.path.join(_MOD_DIR, os.path.basename(path)) + if os.path.isfile(fp): + with open(fp, "rb") as f: + return route.fulfill(status=200, content_type="application/javascript", body=f.read()) + if path == "/configbuilder/preview" and req.method == "POST": + self.preview_posts += 1 + state = json.loads(req.post_data)["state"] + return route.fulfill(status=200, content_type="application/json", + body=json.dumps({"configs_json": UltimateConfigBuilder.state_to_configs_json(state)})) + return route.fulfill(status=404, body="") + + def setup(self, loras, extra=None, **kw): + state = make_state(loras=loras, extra_array_fields=extra, **kw) + self.page.evaluate("s => window.__setup(s)", state) + expect(self.page.locator("#lora-list .cb-item-card")).to_have_count(len(loras)) + return state + + def preview_loras(self, timeout_ms=6000): + """The lora list the preview panel currently shows (waits for the debounced POST to land).""" + last = None + for _ in range(timeout_ms // 50): + txt = self.page.locator("#json-preview").inner_text() + if txt.strip(): + last = json.loads(txt)["configs"][0]["lora"] + return last if isinstance(last, list) else [last] + self.page.wait_for_timeout(50) + raise AssertionError("preview never rendered") + + def wait_loras(self, expected, timeout_ms=6000): + got = None + for _ in range(timeout_ms // 100): + txt = self.page.locator("#json-preview").inner_text() + if txt.strip(): + got = json.loads(txt)["configs"][0]["lora"] + got = got if isinstance(got, list) else [got] + if got == expected: + return got + self.page.wait_for_timeout(100) + raise AssertionError(f"preview lora list {got!r} != {expected!r}") + + def card(self, i): + return self.page.locator("#lora-list .cb-item-card").nth(i) + + def rows(self, i=0): + return self.card(i).locator(".cb-lora-strength-row") + + def add(self, i=0): + self.card(i).locator(".cb-lora-add-strength").click() + + def set_number(self, locator, value): + locator.fill(str(value)) + locator.dispatch_event("change") + + def state(self): + return self.page.evaluate("window.__node.state.config_arrays[0]") + + def iterations(self): + return self.page.locator("#iter").inner_text() + + +@pytest.fixture +def builder(page): + b = Builder(page) + page.on("pageerror", lambda e: b.pageerrors.append(str(e))) + page.route("**/*", b.handle) + page.set_viewport_size({"width": 1200, "height": 1000}) + page.goto(ORIGIN + "/builder.html") + page.wait_for_function("window.__ready === true", timeout=15000) + yield b + assert b.pageerrors == [] + + +BASE = "a.safetensors:1.00:1.00" + + +def test_plus_adds_rows_with_one_slider_each_and_preview_follows(builder): + builder.setup([BASE]) + builder.add() + builder.add() + expect(builder.rows()).to_have_count(2) + for k in range(2): + assert builder.rows().nth(k).locator("input[type=range]").count() == 1 + builder.set_number(builder.rows().nth(0).locator("input[type=number]"), 0.6) + builder.set_number(builder.rows().nth(1).locator("input[type=number]"), 0.8) + builder.wait_loras([BASE, "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"]) + assert builder.state()["lora_extra_strengths"]["a.safetensors"] == [ + {"model": 0.6, "clip": 0.6}, {"model": 0.8, "clip": 0.8}] + expect(builder.page.locator("#iter")).to_contain_text("Iterations: 3") + # the old comma-separated box is gone + assert builder.page.locator("text=Compare Strengths").count() == 0 + + +def test_lock_off_gives_model_and_clip_sliders_per_row(builder): + builder.setup([BASE], {"lora_strength_lock": {"a.safetensors": False}}) + builder.add() + expect(builder.rows()).to_have_count(1) + assert builder.rows().nth(0).locator("input[type=range]").count() == 2 + nums = builder.rows().nth(0).locator("input[type=number]") + builder.set_number(nums.nth(0), 0.6) + builder.set_number(nums.nth(1), 0.4) + builder.wait_loras([BASE, "a.safetensors:0.60:0.40"]) + # switching the lock back on forces CLIP = model on the rows + builder.card(0).get_by_text("More LoRA Options").click() + builder.card(0).locator("label:has-text('Lock Model') input[type=checkbox]").check() + builder.wait_loras([BASE, "a.safetensors:0.60:0.60"]) + expect(builder.rows().nth(0).locator("input[type=range]")).to_have_count(1) + + +def test_remove_row_and_key_is_deleted_when_empty(builder): + builder.setup([BASE]) + builder.add() + builder.add() + expect(builder.rows()).to_have_count(2) + builder.rows().nth(0).locator(".cb-lora-remove-strength").click() + expect(builder.rows()).to_have_count(1) + assert len(builder.state()["lora_extra_strengths"]["a.safetensors"]) == 1 + builder.rows().nth(0).locator(".cb-lora-remove-strength").click() + expect(builder.rows()).to_have_count(0) + assert "a.safetensors" not in (builder.state().get("lora_extra_strengths") or {}) + builder.wait_loras([BASE]) + expect(builder.page.locator("#iter")).to_contain_text("Iterations: 1") + + +def test_two_loras_with_two_strengths_each_give_four_stacked_entries(builder): + builder.setup([BASE, "b.safetensors:0.50:0.50"]) + builder.add(0) + builder.add(1) + builder.set_number(builder.rows(0).nth(0).locator("input[type=number]"), 0.6) + builder.set_number(builder.rows(1).nth(0).locator("input[type=number]"), 0.8) + out = builder.wait_loras([ + "a.safetensors:1.00:1.00 + b.safetensors:0.50:0.50", + "a.safetensors:1.00:1.00 + b.safetensors:0.80:0.80", + "a.safetensors:0.60:0.60 + b.safetensors:0.50:0.50", + "a.safetensors:0.60:0.60 + b.safetensors:0.80:0.80", + ]) + assert len(out) == 4 + # the Iterations readout counts the stack as 4 entries (it used to count 2 per-file) + expect(builder.page.locator("#iter")).to_contain_text("Iterations: 4") + + +def test_row_count_is_capped_at_eight_strengths(builder): + builder.setup([BASE]) + for _ in range(7): + builder.add() + expect(builder.rows()).to_have_count(7) + expect(builder.card(0).locator(".cb-lora-add-strength")).to_be_disabled() + builder.card(0).locator(".cb-lora-add-strength").click(force=True) + expect(builder.rows()).to_have_count(7) + builder.rows().nth(6).locator(".cb-lora-remove-strength").click() + expect(builder.card(0).locator(".cb-lora-add-strength")).to_be_enabled() + + +def test_duplicate_row_is_greyed_and_ignored(builder): + builder.setup([BASE]) + builder.add() # a new row starts equal to the base + expect(builder.rows().nth(0)).to_have_attribute("data-duplicate", "1") + builder.wait_loras([BASE]) + builder.set_number(builder.rows().nth(0).locator("input[type=number]"), 0.7) + expect(builder.rows().nth(0)).to_have_attribute("data-duplicate", "0") + builder.wait_loras([BASE, "a.safetensors:0.70:0.70"]) + + +def test_old_compare_strengths_state_shows_rows_and_converts_on_first_edit(builder): + builder.setup([BASE], {"lora_weight_arrays": {"a.safetensors_model": [0.5, 0.8, 1.0]}}) + # shown as rows right away (base slider = first old value), preview still the old bracket form + expect(builder.rows()).to_have_count(2) + expect(builder.card(0)).to_contain_text("converted from Compare Strengths on first edit") + assert builder.state()["lora_weight_arrays"] == {"a.safetensors_model": [0.5, 0.8, 1.0]} + builder.wait_loras(["a.safetensors:[0.5, 0.8, 1]"]) + # first edit converts + builder.add() + expect(builder.rows()).to_have_count(3) + st = builder.state() + assert st["lora_weight_arrays"] == {} + assert st["loras"] == ["a.safetensors:0.50:0.50"] + assert st["lora_extra_strengths"]["a.safetensors"][:2] == [{"model": 0.8, "clip": 0.8}, {"model": 1.0, "clip": 1.0}] + out = builder.wait_loras(["a.safetensors:0.50:0.50", "a.safetensors:0.80:0.80", "a.safetensors:1.00:1.00"]) + assert not any("[" in s for s in out) + + +def test_extra_rows_follow_their_lora_when_cards_are_reordered(builder): + builder.setup([BASE, "b.safetensors:0.50:0.50"]) + builder.add(0) + builder.set_number(builder.rows(0).nth(0).locator("input[type=number]"), 0.6) + builder.page.evaluate("""() => { + const cards = document.querySelectorAll('#lora-list .cb-item-card'); + const dt = new DataTransfer(); + dt.setData('text/plain', '0'); + dt.setData('application/x-cb-list-id', 'lora-list'); + const r = cards[1].getBoundingClientRect(); + cards[1].dispatchEvent(new DragEvent('drop', {dataTransfer: dt, bubbles: true, cancelable: true, clientY: r.bottom - 1})); + }""") + expect(builder.card(1).locator(".cb-lora-strength-row")).to_have_count(1) + assert builder.state()["loras"][0].startswith("b.safetensors") + expect(builder.rows(0)).to_have_count(0) + assert builder.state()["lora_extra_strengths"] == {"a.safetensors": [{"model": 0.6, "clip": 0.6}]} + builder.wait_loras(["b.safetensors:0.50:0.50 + a.safetensors:1.00:1.00", + "b.safetensors:0.50:0.50 + a.safetensors:0.60:0.60"]) + + +def test_deleting_a_card_drops_its_rows(builder): + builder.setup([BASE, "b.safetensors:0.50:0.50"]) + builder.add(0) + builder.card(0).locator(".cb-header-bar button.danger").click() + expect(builder.page.locator("#lora-list .cb-item-card")).to_have_count(1) + assert "a.safetensors" not in (builder.state().get("lora_extra_strengths") or {}) + + +# ---- Dashboard: a strength sweep keeps its label in "unique only" mode ----------------------------- + +def test_dashboard_label_keeps_strength_when_one_lora_is_swept(page, dashboard_kit): + from tests.e2e.conftest import N_ITEMS # noqa: F401 (same fake origin / manifest builder as the dashboard fixture) + manifest = dashboard_kit["make_manifest"]() + for i, item in enumerate(manifest["items"]): + item["lora"] = "x.safetensors:0.60:0.60" if i % 2 else "x.safetensors:1.00:1.00" + d = dashboard_kit["Dashboard"](page, manifest) + page.set_viewport_size({"width": 1600, "height": 1200}) + page.on("pageerror", lambda e: d.pageerrors.append(str(e))) + page.route("**/*", d.handle) + page.goto(dashboard_kit["ORIGIN"] + "/") + page.wait_for_function(f"typeof processedData!=='undefined' && processedData.length==={N_ITEMS}") + html = page.evaluate("""() => { + labelMode.enabled = true; labelMode.fields.lora = true; labelMode.fields.loraUniqueOnly = true; + computeLabelGlobalValues(); + const sweep = buildLabelOverlay(activeData.find(x => x.lora === 'x.safetensors:0.60:0.60')); + // same LoRA, ONE strength string on every card -> still hidden as "global" + activeData.forEach(x => { x.lora = 'x.safetensors:1.00:1.00'; }); + computeLabelGlobalValues(); + const same = buildLabelOverlay(activeData[0]); + return {sweep, same}; + }""") + assert "x:0.60:0.60" in html["sweep"] + assert "x:" not in html["same"] + assert d.pageerrors == [] diff --git a/tests/fixtures/lora_extra_strengths_cases.json b/tests/fixtures/lora_extra_strengths_cases.json new file mode 100644 index 0000000..594b313 --- /dev/null +++ b/tests/fixtures/lora_extra_strengths_cases.json @@ -0,0 +1,108 @@ +{ + "_comment": "Shared by tests/test_lora_extra_strengths.py (Python emit) and tests/js/lora-extra-strengths.test.js (loraEntryCount). 'array' holds the config-array fields; 'expected' is the exact lora list the Builder must emit; the JS entry count must equal len(expected) (no folder expansion: available_loras is not set).", + "cases": [ + { + "name": "locked_one_lora_two_rows", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": [{"model": 0.6, "clip": 0.6}, {"model": 0.8, "clip": 0.8}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"] + }, + { + "name": "unlocked_pairs", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_strength_lock": {"a.safetensors": false}, + "lora_extra_strengths": {"a.safetensors": [{"model": 0.6, "clip": 0.4}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.40"] + }, + { + "name": "locked_forces_clip_to_model", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": [{"model": 0.6, "clip": 0.1}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60"] + }, + { + "name": "stack_two_by_three_first_lora_slowest", + "array": { + "loras": ["a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"], + "lora_extra_strengths": { + "a.safetensors": [{"model": 0.6, "clip": 0.6}], + "b.safetensors": [{"model": 0.8, "clip": 0.8}, {"model": 0.9, "clip": 0.9}] + } + }, + "expected": [ + "a.safetensors:1.00:1.00 + b.safetensors:0.50:0.50", + "a.safetensors:1.00:1.00 + b.safetensors:0.80:0.80", + "a.safetensors:1.00:1.00 + b.safetensors:0.90:0.90", + "a.safetensors:0.60:0.60 + b.safetensors:0.50:0.50", + "a.safetensors:0.60:0.60 + b.safetensors:0.80:0.80", + "a.safetensors:0.60:0.60 + b.safetensors:0.90:0.90" + ] + }, + { + "name": "folder_entry_and_one_file_stay_separate", + "array": { + "loras": ["foo/", "a.safetensors:1.00:1.00"], + "lora_extra_strengths": { + "foo/": [{"model": 0.6, "clip": 0.6}], + "a.safetensors": [{"model": 0.7, "clip": 0.7}] + } + }, + "expected": ["foo/", "foo/:0.60:0.60", "a.safetensors:1.00:1.00", "a.safetensors:0.70:0.70"] + }, + { + "name": "duplicates_of_base_and_earlier_rows_dropped", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": [{"model": 1.0, "clip": 1.0}, {"model": 0.6, "clip": 0.6}, {"model": 0.604, "clip": 0.6}, {"model": 0.8, "clip": 0.8}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"] + }, + { + "name": "bypassed_lora_rows_ignored", + "array": { + "loras": ["a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"], + "lora_bypass_states": {"a.safetensors": true}, + "lora_extra_strengths": {"a.safetensors": [{"model": 0.6, "clip": 0.6}]} + }, + "expected": ["b.safetensors:0.50:0.50"] + }, + { + "name": "cap_is_eight_strengths_in_total", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": [ + {"model": 0.1, "clip": 0.1}, {"model": 0.2, "clip": 0.2}, {"model": 0.3, "clip": 0.3}, {"model": 0.4, "clip": 0.4}, + {"model": 0.5, "clip": 0.5}, {"model": 0.6, "clip": 0.6}, {"model": 0.7, "clip": 0.7}, {"model": 0.8, "clip": 0.8}, + {"model": 0.9, "clip": 0.9}, {"model": 1.1, "clip": 1.1} + ]} + }, + "expected": [ + "a.safetensors:1.00:1.00", "a.safetensors:0.10:0.10", "a.safetensors:0.20:0.20", "a.safetensors:0.30:0.30", + "a.safetensors:0.40:0.40", "a.safetensors:0.50:0.50", "a.safetensors:0.60:0.60", "a.safetensors:0.70:0.70" + ] + }, + { + "name": "new_rows_win_over_old_weight_arrays", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_weight_arrays": {"a.safetensors_model": [0.1, 0.2, 0.3]}, + "lora_extra_strengths": {"a.safetensors": [{"model": 0.6, "clip": 0.6}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60"] + }, + { + "name": "invalid_rows_are_dropped", + "array": { + "loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": [{"model": "abc"}, {"model": null}, {"model": 0.6, "clip": "x"}, {"model": 0.7}]} + }, + "expected": ["a.safetensors:1.00:1.00", "a.safetensors:0.70:0.70"] + } + ] +} diff --git a/tests/js/lora-extra-strengths.test.js b/tests/js/lora-extra-strengths.test.js new file mode 100644 index 0000000..301031f --- /dev/null +++ b/tests/js/lora-extra-strengths.test.js @@ -0,0 +1,108 @@ +// LoRA strength rows (Roadmap 12). The helpers live in an ES module, so this test slices the pure +// functions out of the source and evaluates them (same trick as workflow-config-to-array.test.js). +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '../..'); +const src = fs.readFileSync(path.join(root, 'web/conf_builder/conf-builder-utilities.js'), 'utf8'); + +function grab(name) { + const re = new RegExp('(?:export )?function ' + name + '\\('); + const m = re.exec(src); + assert.ok(m, 'function ' + name + ' not found'); + let i = src.indexOf('{', m.index), depth = 0, j = i; + for (; j < src.length; j++) { + if (src[j] === '{') depth++; + else if (src[j] === '}') { depth--; if (depth === 0) break; } + } + return src.slice(m.index, j + 1).replace(/^export /, ''); +} +function grabConst(name) { + const re = new RegExp('(?:export )?const ' + name + ' = '); + const m = re.exec(src); + assert.ok(m, 'const ' + name + ' not found'); + // a one-line const, or an arrow function that ends at the first "\n};" + const eol = src.indexOf('\n', m.index); + const oneLine = !src.slice(m.index, eol).trimEnd().endsWith('{'); + const stop = oneLine ? eol : src.indexOf('\n};', m.index) + 3; + return src.slice(m.index, stop).replace(/^export /, ''); +} + +const code = [ + grabConst('MAX_LORA_STRENGTHS'), grabConst('_num2'), + ...['normalizePath', 'parseLoraString', 'normalizeExtraStrengths', 'legacyWeightArraysToExtras', + '_baseOfLoraString', 'loraEntryCount'].map(grab), +].join('\n'); +const L = new Function(code + '\nreturn { normalizeExtraStrengths, legacyWeightArraysToExtras, loraEntryCount, MAX_LORA_STRENGTHS };')(); + +const rows = (...pairs) => pairs.map(([model, clip]) => ({ model, clip })); + +test('normalizeExtraStrengths: rounds to 2 decimals, forces clip when locked', () => { + const r = L.normalizeExtraStrengths(rows([0.604, 0.1], [0.8, 0.4]), true, 1, 1); + assert.deepStrictEqual(r.effective, rows([0.6, 0.6], [0.8, 0.8])); + const u = L.normalizeExtraStrengths(rows([0.604, 0.1], [0.8, 0.4]), false, 1, 1); + assert.deepStrictEqual(u.effective, rows([0.6, 0.1], [0.8, 0.4])); + assert.deepStrictEqual(L.normalizeExtraStrengths([{ model: 0.7 }], false, 1, 1).effective, rows([0.7, 0.7])); +}); + +test('normalizeExtraStrengths: drops non-finite / junk rows, tolerates non-lists', () => { + const bad = [null, 'abc', { model: 'abc' }, { model: null }, { model: NaN }, { model: Infinity }, { model: 0.5, clip: 'x' }, { model: true }, { model: 0.5 }]; + assert.deepStrictEqual(L.normalizeExtraStrengths(bad, true, 1, 1).effective, rows([0.5, 0.5])); + for (const v of [null, undefined, 'x', 5, {}]) assert.deepStrictEqual(L.normalizeExtraStrengths(v, true, 1, 1), { rows: [], effective: [] }); +}); + +test('normalizeExtraStrengths: duplicates of the base or of an earlier row are flagged, not effective', () => { + const r = L.normalizeExtraStrengths(rows([1, 1], [0.6, 0.6], [0.604, 0.6], [0.8, 0.8]), true, 1, 1); + assert.deepStrictEqual(r.rows.map(x => x.duplicate), [true, false, true, false]); + assert.deepStrictEqual(r.effective, rows([0.6, 0.6], [0.8, 0.8])); + // unknown base (bracket string): nothing is a base duplicate + assert.strictEqual(L.normalizeExtraStrengths(rows([1, 1]), true, null, null).effective.length, 1); +}); + +test('normalizeExtraStrengths: at most 8 strengths in total (7 extra rows)', () => { + const many = rows(...Array.from({ length: 10 }, (_, i) => [(i + 1) / 10, (i + 1) / 10])); + assert.strictEqual(L.normalizeExtraStrengths(many, true, 5, 5).rows.length, 7); + assert.strictEqual(L.MAX_LORA_STRENGTHS, 8); +}); + +test('legacyWeightArraysToExtras: model list -> CLIP = model, first value becomes the base', () => { + const r = L.legacyWeightArraysToExtras({ 'a_model': [0.5, 0.8, 1] }, 'a', 1, 1); + assert.deepStrictEqual(r.base, { model: 0.5, clip: 0.5 }); + assert.deepStrictEqual(r.extras, rows([0.8, 0.8], [1, 1])); +}); + +test('legacyWeightArraysToExtras: model + clip lists -> every pair, capped at 8; clip-only; nothing to convert', () => { + const r = L.legacyWeightArraysToExtras({ 'a_model': [0.5, 0.8], 'a_clip': [0.4, 0.9] }, 'a', 1, 1); + assert.deepStrictEqual([r.base, ...r.extras], rows([0.5, 0.4], [0.5, 0.9], [0.8, 0.4], [0.8, 0.9])); + const big = L.legacyWeightArraysToExtras({ 'a_model': [1, 2, 3, 4, 5], 'a_clip': [1, 2, 3] }, 'a', 1, 1); + assert.strictEqual(1 + big.extras.length, 8); + const c = L.legacyWeightArraysToExtras({ 'a_clip': [0.4, 0.9] }, 'a', 0.7, 0.7); + assert.deepStrictEqual([c.base, ...c.extras], rows([0.7, 0.4], [0.7, 0.9])); + assert.strictEqual(L.legacyWeightArraysToExtras({ 'a_model': [1] }, 'a', 1, 1), null); + assert.strictEqual(L.legacyWeightArraysToExtras({}, 'a', 1, 1), null); + assert.strictEqual(L.legacyWeightArraysToExtras(null, 'a', 1, 1), null); + assert.strictEqual(L.legacyWeightArraysToExtras({ 'b_model': [1, 2] }, 'a', 1, 1), null); +}); + +test('loraEntryCount equals the number of emitted lora strings for every shared fixture case', () => { + const cases = JSON.parse(fs.readFileSync(path.join(root, 'tests/fixtures/lora_extra_strengths_cases.json'), 'utf8')).cases; + assert.ok(cases.length >= 8); + for (const c of cases) { + assert.strictEqual(L.loraEntryCount(c.array, c.available_loras || null), c.expected.length, c.name); + } +}); + +test('loraEntryCount: stacked LoRAs are ONE entry (no per-file overcount), legacy arrays and folders count', () => { + assert.strictEqual(L.loraEntryCount({ loras: ['a:1.00:1.00', 'b:0.50:0.50'] }, null), 1); + assert.strictEqual(L.loraEntryCount({ loras: ['None'] }, null), 1); + assert.strictEqual(L.loraEntryCount({ loras: [] }, null), 1); + assert.strictEqual(L.loraEntryCount({ loras: ['a:1:1'], lora_weight_arrays: { 'a_model': [1, 2, 3] } }, null), 3); + assert.strictEqual(L.loraEntryCount({ loras: ['a:1:1'], lora_weight_arrays: { 'a_model': [1, 2], 'a_clip': [1, 2, 3] } }, null), 6); + const avail = ['foo/x.safetensors', 'foo/y.safetensors', 'z.safetensors']; + assert.strictEqual(L.loraEntryCount({ loras: ['foo/'] }, avail), 2); + assert.strictEqual(L.loraEntryCount({ loras: ['foo/', 'z.safetensors:1:1'] }, avail), 3); + assert.strictEqual(L.loraEntryCount({ loras: ['foo/'], lora_extra_strengths: { 'foo/': rows([0.6, 0.6]) } }, avail), 4); + assert.strictEqual(L.loraEntryCount({ loras: ['a:1:1'], lora_bypass_states: { a: true } }, null), 1); +}); diff --git a/tests/test_lora_extra_strengths.py b/tests/test_lora_extra_strengths.py new file mode 100644 index 0000000..a6b48da --- /dev/null +++ b/tests/test_lora_extra_strengths.py @@ -0,0 +1,233 @@ +"""Roadmap item 12: LoRA strength rows ("+" in the Builder) -> state key lora_extra_strengths. + +Python half of the contract (config_builder_node.process_lora_array). The JS half +(normalizeExtraStrengths / loraEntryCount) is tested in tests/js/lora-extra-strengths.test.js +against the same fixture, tests/fixtures/lora_extra_strengths_cases.json. +""" +import copy +import json +import math +import os + +import pytest + +from config_builder_node import UltimateConfigBuilder +from config_utils import config_fingerprint, expand_configs +from tests.test_state_to_configs_json import make_state + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "lora_extra_strengths_cases.json") + + +def _state(array=None, **kw): + array = dict(array or {}) + loras = array.pop("loras", ["None"]) + bypass = array.pop("lora_bypass_states", None) + weight = array.pop("lora_weight_arrays", None) + return make_state(loras=loras, lora_bypass_states=bypass, lora_weight_arrays=weight, + extra_array_fields=array or None, **kw) + + +def _configs(state): + return json.loads(UltimateConfigBuilder.state_to_configs_json(state))["configs"] + + +def _loras(state): + """The emitted lora list of the (single) config object, always as a list.""" + configs = _configs(state) + assert len(configs) == 1, configs + lora = configs[0]["lora"] + return lora if isinstance(lora, list) else [lora] + + +def _rows(*pairs): + return [{"model": m, "clip": c} for m, c in pairs] + + +# 1 ----------------------------------------------------------------------------- +def test_locked_one_lora_two_rows(): + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.6), (0.8, 0.8))}}) + assert _loras(state) == ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"] + + +# 2 ----------------------------------------------------------------------------- +def test_unlocked_rows_stay_pairs(): + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_strength_lock": {"a.safetensors": False}, + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.4))}}) + out = _loras(state) + assert out == ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.40"] + assert len(out) == 2 # base + 1, not a model x clip product + + +# 3 ----------------------------------------------------------------------------- +def test_locked_forces_clip_to_model(): + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.1))}}) + assert _loras(state)[1] == "a.safetensors:0.60:0.60" + # missing lock key == locked (the UI default) + state2 = _state({"loras": ["a.safetensors:1.00:1.00"], "lora_strength_lock": {"other": False}, + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.1))}}) + assert _loras(state2)[1] == "a.safetensors:0.60:0.60" + + +# 4 ----------------------------------------------------------------------------- +def test_two_stacked_loras_cross_product_first_slowest(): + state = _state({"loras": ["a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"], + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.6)), + "b.safetensors": _rows((0.8, 0.8), (0.9, 0.9))}}) + out = _loras(state) + assert len(out) == 6 + assert out[0] == "a.safetensors:1.00:1.00 + b.safetensors:0.50:0.50" + assert out[1] == "a.safetensors:1.00:1.00 + b.safetensors:0.80:0.80" + assert out[3] == "a.safetensors:0.60:0.60 + b.safetensors:0.50:0.50" + assert out[5] == "a.safetensors:0.60:0.60 + b.safetensors:0.90:0.90" + + +# 5 ----------------------------------------------------------------------------- +def test_folder_entry_is_never_stacked_with_a_file_lora(): + state = _state({"loras": ["foo/", "a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"foo/": _rows((0.6, 0.6)), + "a.safetensors": _rows((0.7, 0.7))}}) + assert _loras(state) == ["foo/", "foo/:0.60:0.60", "a.safetensors:1.00:1.00", "a.safetensors:0.70:0.70"] + + +# 6 ----------------------------------------------------------------------------- +def test_duplicates_of_base_and_earlier_rows_are_dropped(): + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": _rows((1.0, 1.0), (0.6, 0.6), (0.604, 0.6), (0.8, 0.8))}}) + assert _loras(state) == ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"] + + +# 7 ----------------------------------------------------------------------------- +@pytest.mark.parametrize("raw", [ + "not-a-list", None, 5, {"model": 1}, [None, "abc", {"model": "abc"}, {"model": None}, + {"model": float("nan")}, {"model": float("inf")}, + {"model": 0.5, "clip": float("-inf")}, {"model": True}, []], +]) +def test_invalid_rows_do_not_raise_and_are_ignored(raw): + state = _state({"loras": ["a.safetensors:1.00:1.00"], "lora_extra_strengths": {"a.safetensors": raw}}) + assert _loras(state) == ["a.safetensors:1.00:1.00"] + + +def test_invalid_map_shapes_do_not_raise(): + for bad in ("x", [1, 2], 7): + state = _state({"loras": ["a.safetensors:1.00:1.00"], "lora_extra_strengths": bad}) + assert _loras(state) == ["a.safetensors:1.00:1.00"] + # leniently accepted: [m, c] pairs and a row without clip + state = _state({"loras": ["a.safetensors:1.00:1.00"], "lora_strength_lock": {"a.safetensors": False}, + "lora_extra_strengths": {"a.safetensors": [[0.6, 0.4], {"model": 0.7}]}}) + assert _loras(state) == ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.40", "a.safetensors:0.70:0.70"] + + +# 8 ----------------------------------------------------------------------------- +def test_cap_is_eight_strengths_in_total(): + extras = _rows(*[(round(0.1 * i, 2), round(0.1 * i, 2)) for i in range(1, 11)]) # 0.1 .. 1.0 (1.0 == base) + state = _state({"loras": ["a.safetensors:1.00:1.00"], "lora_extra_strengths": {"a.safetensors": extras}}) + out = _loras(state) + assert len(out) == 8 + assert out[0] == "a.safetensors:1.00:1.00" + assert out[-1] == "a.safetensors:0.70:0.70" + # the helper itself + cleaned = UltimateConfigBuilder._clean_extra_strengths(extras, True, 1.0, 1.0) + assert len(cleaned) == 7 + + +# 9 ----------------------------------------------------------------------------- +def test_bypassed_lora_extras_are_ignored(): + state = _state({"loras": ["a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"], + "lora_bypass_states": {"a.safetensors": True}, + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.6))}}) + assert _loras(state) == ["b.safetensors:0.50:0.50"] + + +# 10 ---------------------------------------------------------------------------- +@pytest.mark.parametrize("loras,weight", [ + (["myLora:0.80:0.50"], None), + (["a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"], None), + (["foo/", "a.safetensors:0.70:0.70"], None), + (["Sexy-IL-v11/:1.00:1.00"], {"Sexy-IL-v11/_model": [0, 1, 2, 5, 10]}), + (["a.safetensors:1.00:1.00"], {"a.safetensors_model": [0.5, 0.8], "a.safetensors_clip": [0.4, 0.9]}), +]) +def test_states_without_the_key_are_byte_identical(loras, weight): + base = make_state(loras=list(loras), lora_weight_arrays=copy.deepcopy(weight)) + with_empty = make_state(loras=list(loras), lora_weight_arrays=copy.deepcopy(weight), + extra_array_fields={"lora_extra_strengths": {}}) + with_unrelated = make_state(loras=list(loras), lora_weight_arrays=copy.deepcopy(weight), + extra_array_fields={"lora_extra_strengths": {"nobody.safetensors": _rows((0.6, 0.6))}}) + expected = UltimateConfigBuilder.state_to_configs_json(base) + assert UltimateConfigBuilder.state_to_configs_json(with_empty) == expected + assert UltimateConfigBuilder.state_to_configs_json(with_unrelated) == expected + + +# 11 ---------------------------------------------------------------------------- +def test_new_rows_win_over_old_weight_arrays_without_brackets(): + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_weight_arrays": {"a.safetensors_model": [0.1, 0.2, 0.3]}, + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.6))}}) + out = _loras(state) + assert out == ["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60"] + assert not any("[" in s for s in out) + # an EMPTY new entry leaves the old arrays in charge + state = _state({"loras": ["a.safetensors:1.00:1.00"], + "lora_weight_arrays": {"a.safetensors_model": [0.1, 0.2, 0.3]}, + "lora_extra_strengths": {"a.safetensors": []}}) + assert _loras(state) == ["a.safetensors:[0.1, 0.2, 0.3]"] + + +# 12 ---------------------------------------------------------------------------- +def test_expands_to_n_times_the_other_axes_and_keeps_base_fingerprint(): + array = {"loras": ["a.safetensors:1.00:1.00"], + "lora_extra_strengths": {"a.safetensors": _rows((0.6, 0.6), (0.8, 0.8))}} + with_rows = _configs(_state(array, samplers=["euler", "dpmpp_2m"])) + without = _configs(_state({"loras": ["a.safetensors:1.00:1.00"]}, samplers=["euler", "dpmpp_2m"])) + + def expand(configs): + return expand_configs(configs, [""], [""], [1.0], 0, [], None) + + jobs, base_jobs = expand(with_rows), expand(without) + assert len(base_jobs) == 2 + assert len(jobs) == 2 * 3 # samplers x (base + 2 rows) + emitted = sorted({j["lora"] for j in jobs}) + assert emitted == sorted(["a.safetensors:1.00:1.00", "a.safetensors:0.60:0.60", "a.safetensors:0.80:0.80"]) + # only the lora differs between the copies of one sampler's job + for sampler in ("euler", "dpmpp_2m"): + same = [{k: v for k, v in j.items() if k not in ("lora", "lora_expanded")} for j in jobs if j["sampler"] == sampler] + assert all(s == same[0] for s in same) + # the base job keeps its identity, so a resumed run still skips it + fp = lambda j: config_fingerprint(j, 512, 512, seed=0) + base_fps = {fp(j) for j in base_jobs} + base_in_new = {fp(j) for j in jobs if j["lora"] == "a.safetensors:1.00:1.00"} + assert base_fps == base_in_new + assert len({fp(j) for j in jobs}) == len(jobs) + + +# 13 ---------------------------------------------------------------------------- +def _cases(): + with open(FIXTURE, encoding="utf-8") as f: + return json.load(f)["cases"] + + +@pytest.mark.parametrize("case", _cases(), ids=lambda c: c["name"]) +def test_shared_fixture_cases(case): + assert _loras(_state(copy.deepcopy(case["array"]))) == case["expected"] + + +# Existing bug (plan item 7), NOT fixed by this feature: with >= 2 stackable LoRAs the combined +# stack replaces the list, so a folder entry ("name/") in the same array is silently dropped. +@pytest.mark.xfail(reason="folder entries are dropped when 2+ stackable LoRAs combine (existing behaviour)") +def test_folder_entry_survives_alongside_a_stack(): + state = _state({"loras": ["foo/", "a.safetensors:1.00:1.00", "b.safetensors:0.50:0.50"]}) + out = _loras(state) + assert "foo/" in out + assert "a.safetensors:1.00:1.00 + b.safetensors:0.50:0.50" in out + + +def test_base_strengths_mirror_parse_lora_definition(): + f = UltimateConfigBuilder._base_strengths + assert f("a") == (1.0, 1.0) + assert f("a:0.5") == (0.5, 0.5) # missing CLIP = model + assert f("a:0.5:0.25") == (0.5, 0.25) + assert f("a:[0.5, 0.8]") is None + assert f("a:abc") is None + assert all(math.isfinite(x) for x in f("a:2:3")) diff --git a/web/conf_builder/conf-builder-config-management.js b/web/conf_builder/conf-builder-config-management.js index c746e96..4b6c854 100644 --- a/web/conf_builder/conf-builder-config-management.js +++ b/web/conf_builder/conf-builder-config-management.js @@ -9,6 +9,9 @@ import { parseLoraString, buildLoraString, getIterationCount, + normalizeExtraStrengths, + legacyWeightArraysToExtras, + MAX_LORA_STRENGTHS, countPromptCombinations, expandPromptPreview, @@ -1223,6 +1226,7 @@ export function createConfigArrayElement(node, configArray, arrayIdx, modelLists const iterationCount = getIterationCount(configArray); const countDisplay = document.createElement("div"); countDisplay.style.cssText = "color: #00cc00; font-family: monospace; font-size: 13px; font-weight: bold; display: flex; align-items: center;"; + countDisplay.setAttribute("data-cb-iter-count", String(arrayIdx)); countDisplay.innerHTML = `⏱️ Iterations: ${iterationCount}`; controlsBar.appendChild(countDisplay); @@ -1596,6 +1600,36 @@ export function createModelElement(node, modelEntry, arrayIdx, modelIdx, modelLi // --- LORA ELEMENT CREATOR --- +/** Update the "Iterations" readout of one config array in place (no full re-render). */ +export function refreshIterationCount(node, arrayIdx) { + const el = node.htmlContainer?.querySelector(`[data-cb-iter-count="${arrayIdx}"]`); + const arr = node.state?.config_arrays?.[arrayIdx]; + if (!el || !arr) return; + el.innerHTML = `⏱️ Iterations: ${getIterationCount(arr)}`; +} + +// Extra strength rows are keyed by LoRA name (like bypass/lock); two cards with the same name share them. +function loraNameInUse(configArray, name) { + return (configArray.loras || []).some(l => parseLoraString(l).name === name); +} + +/** The card at loraIdx was renamed (oldName -> its new name): move its rows along unless another card still uses oldName. */ +function relocateLoraExtras(node, arrayIdx, loraIdx, oldName) { + const c = node.state.config_arrays[arrayIdx]; + const map = c.lora_extra_strengths; + if (!map || map[oldName] === undefined) return; + const newName = parseLoraString(c.loras[loraIdx]).name; + if (newName === oldName || loraNameInUse(c, oldName)) return; + const rows = map[oldName]; + delete map[oldName]; + if (newName && newName !== "None" && map[newName] === undefined) map[newName] = rows; +} + +/** A card was deleted: drop its rows unless another card still uses the name. */ +function dropLoraExtrasIfUnused(configArray, name) { + if (configArray.lora_extra_strengths && !loraNameInUse(configArray, name)) delete configArray.lora_extra_strengths[name]; +} + export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLoras, loraFolders) { const div = document.createElement("div"); @@ -1622,7 +1656,7 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor const isBypassed = node.state.config_arrays[arrayIdx].lora_bypass_states[parsed.name] || false; // Get strength lock state (default to true - locked by default) - const isStrengthLocked = node.state.config_arrays[arrayIdx].lora_strength_lock[parsed.name] !== false; + let isStrengthLocked = node.state.config_arrays[arrayIdx].lora_strength_lock[parsed.name] !== false; // Header with bypass toggle const header = document.createElement("div"); @@ -1684,6 +1718,7 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor deleteBtn.textContent = "✖"; deleteBtn.onclick = () => { node.state.config_arrays[arrayIdx].loras.splice(loraIdx, 1); + dropLoraExtrasIfUnused(node.state.config_arrays[arrayIdx], parsed.name); node.saveState(); debouncedRenderUI(node); }; @@ -1728,6 +1763,7 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor if (typeSelect.value === "folder") node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString("/", currentModelStr, currentClipStr); else if (typeSelect.value === "combined") node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString("/*", currentModelStr, currentClipStr); else node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString("None", currentModelStr, currentClipStr); + relocateLoraExtras(node, arrayIdx, loraIdx, parsed.name); node.saveState(); debouncedRenderUI(node); }; @@ -1747,6 +1783,7 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor ? normalizePath(selectedName) + "*" : normalizePath(selectedName); node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString(finalName, currentModelStr, currentClipStr); + relocateLoraExtras(node, arrayIdx, loraIdx, parsed.name); node.saveState(); debouncedRenderUI(node); }, @@ -1754,19 +1791,169 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor ); contentDiv.appendChild(nameSearchable); - // Check if this LoRA has weight arrays stored - if (!node.state.config_arrays[arrayIdx].lora_weight_arrays) { - node.state.config_arrays[arrayIdx].lora_weight_arrays = {}; + // --- Strength rows: "+" adds another strength to compare (state: lora_extra_strengths[name]) --- + // Row 0 is the base sliders (loras[loraIdx] = "name:m:c"); every extra row is one more + // {model, clip} pair. Python (process_lora_array) emits one concrete lora string per row. + const cfgOf = () => node.state.config_arrays[arrayIdx]; + const loraKey = parsed.name; + const roundTo2 = (v) => Math.round(v * 100) / 100; + const asNum = (v) => (Number.isFinite(Number(v)) ? Number(v) : 1); + const currentLoraName = () => (isCombined ? cleanName + "*" : parsed.name); + + // Old "Compare Strengths" data (lora_weight_arrays): shown as rows right away, but only + // converted into lora_extra_strengths on the first edit (ensureMigrated). + const hasStateRows = () => { + const r = cfgOf().lora_extra_strengths?.[loraKey]; + return Array.isArray(r) && r.length > 0; + }; + let legacyView = hasStateRows() + ? null + : legacyWeightArraysToExtras(cfgOf().lora_weight_arrays, loraKey, currentModelStr, currentClipStr); + if (legacyView) { + currentModelStr = legacyView.base.model; + currentClipStr = legacyView.base.clip; + // A model x CLIP sweep needs independent CLIP values: show it unlocked. + if ([legacyView.base, ...legacyView.extras].some(r => r.clip !== r.model)) isStrengthLocked = false; } - const weightArrays = node.state.config_arrays[arrayIdx].lora_weight_arrays; - // "Active" = array exists (ANY length). Length-1 arrays pass through Python - // unchanged (only length>1 gets bracket-rewritten), so persisting [1] is safe - // and lets us remember the "user activated compare mode" UI state across reloads. - const isCompareActive = weightArrays[parsed.name + "_model"] !== undefined; + + const ensureMigrated = () => { + const c = cfgOf(); + const wa = c.lora_weight_arrays; + const hasLegacyKeys = !!wa && (wa[loraKey + "_model"] !== undefined || wa[loraKey + "_clip"] !== undefined); + if (!hasLegacyKeys && !legacyView) return; + if (!c.lora_extra_strengths) c.lora_extra_strengths = {}; + if (legacyView && !hasStateRows()) { + c.loras[loraIdx] = buildLoraString(currentLoraName(), currentModelStr, currentClipStr); + if (legacyView.extras.length > 0) c.lora_extra_strengths[loraKey] = legacyView.extras.map(r => ({ model: r.model, clip: r.clip })); + if (!isStrengthLocked) c.lora_strength_lock[loraKey] = false; + } + if (wa) { + delete wa[loraKey + "_model"]; + delete wa[loraKey + "_clip"]; + } + legacyView = null; + }; + + const duplicateMarks = []; // [{rowEl}] aligned with the extra rows currently rendered + const rowsDiv = document.createElement("div"); + rowsDiv.className = "cb-lora-strength-rows"; + rowsDiv.style.cssText = "display: flex; flex-direction: column; gap: 4px; margin: 0 0 4px 12px; padding-left: 6px; border-left: 2px solid #0af;"; + + const addStrengthBtn = document.createElement("button"); + addStrengthBtn.className = "cb-button cb-lora-add-strength"; + addStrengthBtn.textContent = "+"; + addStrengthBtn.style.cssText = "padding: 0 8px; margin-left: 6px; font-weight: bold; color: #0af;"; + addStrengthBtn.title = `Add another strength to compare (max ${MAX_LORA_STRENGTHS}). Each extra strength is one more run of this config.`; + + const currentRows = () => (hasStateRows() ? cfgOf().lora_extra_strengths[loraKey] : (legacyView ? legacyView.extras : [])); + + const updateDuplicateMarks = () => { + const norm = normalizeExtraStrengths(currentRows(), isStrengthLocked, currentModelStr, currentClipStr); + duplicateMarks.forEach((rowEl, k) => { + const dup = !!norm.rows[k]?.duplicate; + rowEl.style.opacity = dup ? "0.45" : "1"; + rowEl.title = dup ? "duplicate, ignored" : ""; + rowEl.dataset.duplicate = dup ? "1" : "0"; + }); + }; + + const afterRowsChanged = () => { + node.saveState(); + refreshIterationCount(node, arrayIdx); + }; + + const setRowValue = (k, field, val) => { + ensureMigrated(); + const rows = cfgOf().lora_extra_strengths?.[loraKey]; + if (!rows || !rows[k]) return; + const v = roundTo2(val); + if (field === "model") { + rows[k].model = v; + if (isStrengthLocked) rows[k].clip = v; + } else { + rows[k].clip = v; + } + updateDuplicateMarks(); + afterRowsChanged(); + }; + + const removeRow = (k) => { + ensureMigrated(); + const c = cfgOf(); + const rows = c.lora_extra_strengths?.[loraKey]; + if (!rows) return; + rows.splice(k, 1); + if (rows.length === 0) delete c.lora_extra_strengths[loraKey]; + renderStrengthRows(); + afterRowsChanged(); + }; + + const renderStrengthRows = () => { + rowsDiv.innerHTML = ""; + duplicateMarks.length = 0; + const rows = currentRows(); + if (legacyView && rows.length > 0) { + const hint = document.createElement("div"); + hint.style.cssText = "font-size: 9px; color: #888;"; + hint.textContent = "converted from Compare Strengths on first edit"; + rowsDiv.appendChild(hint); + } + rows.forEach((row, k) => { + const rowEl = document.createElement("div"); + rowEl.className = "cb-lora-strength-row"; + rowEl.dataset.row = String(k); + rowEl.style.cssText = "display: flex; flex-direction: column; gap: 2px;"; + const makeRemove = () => { + const rm = document.createElement("button"); + rm.className = "cb-button danger cb-lora-remove-strength"; + rm.textContent = "✕"; + rm.style.cssText = "padding: 0 6px; font-size: 10px;"; + rm.title = "Remove this strength"; + rm.onclick = () => removeRow(k); + return rm; + }; + const first = document.createElement("div"); + first.style.cssText = "display: flex; align-items: center; gap: 4px;"; + if (isStrengthLocked) { + const s = createSlider(`Strength #${k + 2}`, asNum(row.model), 0, 2, 0.05, (val) => setRowValue(k, "model", val)); + s.style.flex = "1"; + first.appendChild(s); + first.appendChild(makeRemove()); + rowEl.appendChild(first); + } else { + const s = createSlider(`Model #${k + 2}`, asNum(row.model), 0, 2, 0.05, (val) => setRowValue(k, "model", val)); + s.style.flex = "1"; + first.appendChild(s); + first.appendChild(makeRemove()); + rowEl.appendChild(first); + rowEl.appendChild(createSlider(`CLIP #${k + 2}`, asNum(row.clip), 0, 2, 0.05, (val) => setRowValue(k, "clip", val))); + } + rowsDiv.appendChild(rowEl); + duplicateMarks.push(rowEl); + }); + rowsDiv.style.display = rows.length > 0 ? "flex" : "none"; + addStrengthBtn.disabled = rows.length + 1 >= MAX_LORA_STRENGTHS; + addStrengthBtn.style.opacity = addStrengthBtn.disabled ? "0.4" : "1"; + updateDuplicateMarks(); + }; + + addStrengthBtn.onclick = () => { + ensureMigrated(); + const c = cfgOf(); + if (!c.lora_extra_strengths) c.lora_extra_strengths = {}; + if (!Array.isArray(c.lora_extra_strengths[loraKey])) c.lora_extra_strengths[loraKey] = []; + const rows = c.lora_extra_strengths[loraKey]; + if (rows.length + 1 >= MAX_LORA_STRENGTHS) return; + const m = roundTo2(asNum(currentModelStr)); + rows.push({ model: m, clip: isStrengthLocked ? m : roundTo2(asNum(currentClipStr)) }); + renderStrengthRows(); + afterRowsChanged(); + }; const modelSlider = createSlider("Model Strength", currentModelStr, 0, 2, 0.05, (val) => { + ensureMigrated(); currentModelStr = val; - const currentName = isCombined ? cleanName + "*" : parsed.name; + const currentName = currentLoraName(); // If strength is locked, update both sliders if (isStrengthLocked) { @@ -1783,98 +1970,30 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor if (clipSliderInput) clipSliderInput.value = val; if (clipNumberInput) clipNumberInput.value = val; } + updateDuplicateMarks(); + refreshIterationCount(node, arrayIdx); }); + if (parsed.name !== "None") modelSlider.appendChild(addStrengthBtn); contentDiv.appendChild(modelSlider); - // === Compare Strengths section (locked-semantics) === - // ONE button toggles a SINGLE labeled input. We only persist - // weightArrays[name + "_model"] — Python emits "name:[a, b, c]" (no clip - // part) which the orchestrator expands as N configs each with - // model=clip=value. This avoids the Cartesian explosion that - // "name:[m]:[c]" (two arrays) would trigger. - // - // When Strength Lock is ON (default): label says "Strengths:" because - // the array applies to both model and clip equally. - // When Strength Lock is OFF: label says "Model Strengths:" because the - // clip is fixed at the scalar slider value (set independently below). - const compareBtnRow = document.createElement("div"); - compareBtnRow.style.cssText = "margin: -4px 0 4px 0; display: flex; align-items: center; gap: 4px;"; - - const compareBtn = document.createElement("button"); - compareBtn.className = "cb-btn"; - compareBtn.textContent = isCompareActive ? "✕ Compare Strengths" : "+ Compare Strengths"; - compareBtn.style.cssText = "font-size: 9px; padding: 1px 6px; color: " + (isCompareActive ? "#f80" : "#0af") + ";"; - compareBtn.title = "Test this LoRA at multiple strength values in one run.\n\nClick to activate, then enter comma-separated values (e.g. 0.5, 0.8, 1.0). Each value generates one config — model and clip strength both equal that value (locked semantics).\n\nWith Strength Lock OFF, the scalar Clip slider below sets a fixed clip strength while model varies.\n\nClick again to deactivate."; - compareBtnRow.appendChild(compareBtn); - contentDiv.appendChild(compareBtnRow); - - // Single labeled input - const compareSection = document.createElement("div"); - compareSection.style.cssText = "display: " + (isCompareActive ? "flex" : "none") + "; flex-direction: column; gap: 3px; margin: 0 0 6px 12px; padding: 4px 6px; border-left: 2px solid #0af; background: rgba(0,170,255,0.04);"; - - const strengthRow = document.createElement("div"); - strengthRow.style.cssText = "display: flex; align-items: center; gap: 6px;"; - const strengthLabel = document.createElement("span"); - strengthLabel.textContent = isStrengthLocked ? "Strengths:" : "Model Strengths:"; - strengthLabel.style.cssText = "font-size: 10px; color: #ccc; white-space: nowrap; min-width: 100px;"; - const strengthInput = document.createElement("input"); - strengthInput.type = "text"; - strengthInput.placeholder = "1, 0.8, 0.5"; - strengthInput.value = isCompareActive - ? (weightArrays[parsed.name + "_model"] || [1]).join(", ") - : "1"; - strengthInput.style.cssText = "flex: 1; background: #1a1a1a; color: #ccc; border: 1px solid #444; border-radius: 4px; padding: 2px 6px; font-size: 10px;"; - strengthInput.onchange = () => { - const vals = strengthInput.value.split(",").map(v => parseFloat(v.trim())).filter(v => !isNaN(v)); - if (vals.length > 0) { - weightArrays[parsed.name + "_model"] = vals; - } - // Always remove _clip — Cartesian path is intentionally dropped. - delete weightArrays[parsed.name + "_clip"]; - node.saveState(); - updatePreview(node); - }; - strengthRow.appendChild(strengthLabel); - strengthRow.appendChild(strengthInput); - compareSection.appendChild(strengthRow); - contentDiv.appendChild(compareSection); - - compareBtn.onclick = () => { - const currentlyActive = weightArrays[parsed.name + "_model"] !== undefined; - if (currentlyActive) { - // Deactivate — remove arrays and hide section - delete weightArrays[parsed.name + "_model"]; - delete weightArrays[parsed.name + "_clip"]; - compareSection.style.display = "none"; - compareBtn.textContent = "+ Compare Strengths"; - compareBtn.style.color = "#0af"; - } else { - // Activate — seed model array with [1], never write _clip - weightArrays[parsed.name + "_model"] = [1]; - delete weightArrays[parsed.name + "_clip"]; - compareSection.style.display = "flex"; - compareBtn.textContent = "✕ Compare Strengths"; - compareBtn.style.color = "#f80"; - strengthInput.value = "1"; - // Focus so the user can type immediately - strengthInput.focus(); - strengthInput.select(); - } - node.saveState(); - updatePreview(node); - }; - - // CLIP Slider - conditionally visible based on lock state + // CLIP Slider - only when Model & CLIP are not locked together; sits right under the + // base model slider, followed by the extra strength rows. let clipSliderContainer = null; if (!isStrengthLocked) { clipSliderContainer = createSlider("CLIP Strength", currentClipStr, 0, 2, 0.05, (val) => { + ensureMigrated(); currentClipStr = val; - const currentName = isCombined ? cleanName + "*" : parsed.name; - node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString(currentName, currentModelStr, currentClipStr); + node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString(currentLoraName(), currentModelStr, currentClipStr); node.saveState(); + updateDuplicateMarks(); + refreshIterationCount(node, arrayIdx); }); contentDiv.appendChild(clipSliderContainer); } + if (parsed.name !== "None") { + contentDiv.appendChild(rowsDiv); + renderStrengthRows(); + } // --- COLLAPSIBLE "MORE LORA OPTIONS" SECTION --- if (parsed.name !== "None") { @@ -1923,11 +2042,14 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor strengthLockCheck.onchange = () => { node.state.config_arrays[arrayIdx].lora_strength_lock[parsed.name] = strengthLockCheck.checked; - // If locking, sync CLIP to Model strength + // If locking, sync CLIP to Model strength (the base row and every extra row) if (strengthLockCheck.checked) { + ensureMigrated(); currentClipStr = currentModelStr; const currentName = isCombined ? cleanName + "*" : parsed.name; node.state.config_arrays[arrayIdx].loras[loraIdx] = buildLoraString(currentName, currentModelStr, currentClipStr); + const lockedRows = node.state.config_arrays[arrayIdx].lora_extra_strengths?.[parsed.name]; + if (Array.isArray(lockedRows)) lockedRows.forEach(r => { r.clip = r.model; }); } node.saveState(); @@ -1936,6 +2058,7 @@ export function createLoraElement(node, loraStr, arrayIdx, loraIdx, availableLor strengthLockLabel.appendChild(strengthLockCheck); strengthLockLabel.appendChild(document.createTextNode("🔒 Lock Model & CLIP Strength Together")); + strengthLockLabel.title = "On: each strength row is one value used for both Model and CLIP.\nOff: every row has its own Model and CLIP sliders (one job per row)."; moreOptionsContent.appendChild(strengthLockLabel); // 2. Auto Append Trigger Words Section @@ -4763,10 +4886,12 @@ export function renderLorasSection(node, div, configArray, arrayIdx, availableLo // LoRAs section presets const lorasPresetRow = createSectionPresetRow("loras", - () => ({ loras: configArray.loras, lora_weight_arrays: configArray.lora_weight_arrays || {}, lora_strength_lock: configArray.lora_strength_lock || {} }), + () => ({ loras: configArray.loras, lora_weight_arrays: configArray.lora_weight_arrays || {}, lora_extra_strengths: configArray.lora_extra_strengths || {}, lora_strength_lock: configArray.lora_strength_lock || {} }), (data) => { node.state.config_arrays[arrayIdx].loras = data.loras || ["None"]; if (data.lora_weight_arrays) node.state.config_arrays[arrayIdx].lora_weight_arrays = data.lora_weight_arrays; + if (data.lora_extra_strengths) node.state.config_arrays[arrayIdx].lora_extra_strengths = data.lora_extra_strengths; + else delete node.state.config_arrays[arrayIdx].lora_extra_strengths; if (data.lora_strength_lock) node.state.config_arrays[arrayIdx].lora_strength_lock = data.lora_strength_lock; }, node diff --git a/web/conf_builder/conf-builder-readme.md b/web/conf_builder/conf-builder-readme.md index 8c1991c..acfe4af 100644 --- a/web/conf_builder/conf-builder-readme.md +++ b/web/conf_builder/conf-builder-readme.md @@ -39,6 +39,13 @@ The refactoring splits the original monolithic `config_builder.js` into four mod * **Iteration Calculation**: * `getIterationCount(configArray)`: A critical logic function that calculates the total number of generation steps by multiplying the counts of models, LoRAs, samplers, schedulers, etc. It handles complex logic for folders and wildcards. +* `loraEntryCount(configArray, availableLoras)`: The LoRA factor of that product. It mirrors `process_lora_array` in `config_builder_node.py`: stacked LoRAs become one entry per combination of their strengths, folders add their file count times their strength variants. + + +* **LoRA Strength Rows** (the + button): +* State key `config_arrays[i].lora_extra_strengths`: `{ "": [ {"model": 0.6, "clip": 0.6}, ... ] }`. Only the EXTRA rows are stored; row 0 stays in `loras[j]` (`"name:m:c"`). Keyed by LoRA name like `lora_bypass_states` / `lora_strength_lock`. Values are rounded to 2 decimals, at most 7 extra rows (8 strengths in total). When `lora_strength_lock[name]` is not `false`, Python forces `clip = model`. +* Python emits one concrete string per row on the config's `lora` list (`"a:1.00:1.00"`, `"a:0.60:0.60"`, ...), and every combination for stacks. A non-empty entry replaces the old `lora_weight_arrays` entries of that name; states without the key behave exactly as before. +* `normalizeExtraStrengths(...)` is the JS twin of `_clean_extra_strengths` (validation, cap, duplicate flags). `legacyWeightArraysToExtras(...)` converts old Compare Strengths data (`lora_weight_arrays`) into rows on the first edit. * **Config Conversion**: diff --git a/web/conf_builder/conf-builder-utilities.js b/web/conf_builder/conf-builder-utilities.js index 9721efd..f07f63b 100644 --- a/web/conf_builder/conf-builder-utilities.js +++ b/web/conf_builder/conf-builder-utilities.js @@ -501,6 +501,151 @@ export function expandPromptPreview(groups, limit = 20) { return all.slice(0, limit); } +// --- LORA STRENGTH ROWS (the "+" button in the LoRAs section) --- + +export const MAX_LORA_STRENGTHS = 8; // base row + up to 7 extra rows per LoRA + +const _num2 = (v) => { + if (v === null || v === undefined || v === "" || typeof v === "boolean") return null; + const f = typeof v === "number" ? v : Number(v); + return Number.isFinite(f) ? Math.round(f * 100) / 100 : null; +}; + +/** + * Validate the extra strength rows of one LoRA (state key lora_extra_strengths[name]). + * JS twin of UltimateConfigBuilder._clean_extra_strengths (config_builder_node.py): same + * rules, so the Iterations count and the UI "duplicate" greying agree with the preview. + * list: [{model, clip}, ...] (a missing clip means "same as model") + * locked: clip is forced to model. baseModel/baseClip: the base row, or null (unknown). + * Returns { rows, effective }: rows = valid rows (rounded to 2 decimals, capped at + * maxTotal - 1) each with a `duplicate` flag; effective = the rows that are not duplicates + * of the base or of an earlier row, as plain {model, clip}. + */ +export function normalizeExtraStrengths(list, locked, baseModel, baseClip, maxTotal = MAX_LORA_STRENGTHS) { + if (!Array.isArray(list)) return { rows: [], effective: [] }; + let rows = []; + for (const item of list) { + let mRaw, cRaw; + if (Array.isArray(item)) { mRaw = item[0]; cRaw = item[1]; } + else if (item && typeof item === "object") { mRaw = item.model; cRaw = item.clip; } + else continue; + const m = _num2(mRaw); + if (m === null) continue; + let c = (cRaw === null || cRaw === undefined) ? m : _num2(cRaw); + if (c === null) continue; + if (locked) c = m; + rows.push({ model: m, clip: c }); + } + rows = rows.slice(0, Math.max(0, maxTotal - 1)); + const seen = new Set(); + const bm = _num2(baseModel), bc = _num2(baseClip); + if (bm !== null && bc !== null) seen.add(bm + "|" + bc); + const out = [], effective = []; + for (const r of rows) { + const key = r.model + "|" + r.clip; + const duplicate = seen.has(key); + seen.add(key); + out.push({ model: r.model, clip: r.clip, duplicate }); + if (!duplicate) effective.push({ model: r.model, clip: r.clip }); + } + return { rows: out, effective }; +} + +/** + * Old "Compare Strengths" state (lora_weight_arrays[name + "_model" / "_clip"]) -> the new + * row model, mirroring what Python emitted for it (_apply_weight_array): a model list with + * no CLIP list means CLIP = model; both lists mean every model x CLIP pair; a CLIP list + * alone sweeps CLIP at the base model strength. Returns + * { base: {model, clip}, extras: [{model, clip}] } (at most MAX_LORA_STRENGTHS rows in + * total) or null when there is nothing to convert (lists with fewer than 2 values). + */ +export function legacyWeightArraysToExtras(weightArrays, name, baseModel, baseClip) { + if (!weightArrays || typeof weightArrays !== "object") return null; + const toNums = (arr) => Array.isArray(arr) ? arr.map(_num2).filter(v => v !== null) : []; + const mArr = toNums(weightArrays[name + "_model"]); + const cArr = toNums(weightArrays[name + "_clip"]); + if (mArr.length < 2 && cArr.length < 2) return null; + let pairs; + if (mArr.length > 1 && cArr.length > 1) { + pairs = []; + for (const m of mArr) for (const c of cArr) pairs.push({ model: m, clip: c }); + } else if (mArr.length > 1) { + pairs = mArr.map(m => ({ model: m, clip: m })); + } else { + // CLIP-only list: Python emits "name::[c, ...]", the model stays at the base value + const bm = _num2(baseModel); + if (bm === null) return null; + pairs = cArr.map(c => ({ model: bm, clip: c })); + } + pairs = pairs.slice(0, MAX_LORA_STRENGTHS); + return { base: pairs[0], extras: pairs.slice(1) }; +} + +// "name[:m[:c]]" -> {name, model, clip} with the same defaults as Python's parse_lora_definition +// (missing strengths = 1.0, missing CLIP = model). model/clip are null for bracket strings. +function _baseOfLoraString(loraStr) { + const norm = normalizePath(String(loraStr || "")); + const parts = norm.split(":"); + if (norm.includes("[")) return { name: parts[0], model: null, clip: null }; + const m = parts.length > 1 && parts[1].trim() !== "" ? parseFloat(parts[1]) : 1.0; + const c = parts.length > 2 && parts[2].trim() !== "" ? parseFloat(parts[2]) : m; + return { name: parts[0], model: Number.isFinite(m) ? m : null, clip: Number.isFinite(c) ? c : null }; +} + +/** + * How many LoRA entries one config array produces, mirroring process_lora_array in + * config_builder_node.py: bypassed entries skipped; 2+ stackable entries (anything that is + * not a "folder/" reference) combine into ONE stack whose entries are every combination of + * the LoRAs' strengths; otherwise the entries add up (a folder counts its files). + */ +export function loraEntryCount(configArray, availableLoraList) { + const loras = configArray.loras; + if (!loras || loras.length === 0) return 1; + const extras = configArray.lora_extra_strengths || {}; + const lockMap = configArray.lora_strength_lock || {}; + const legacy = configArray.lora_weight_arrays || {}; + + const variantsFor = (loraStr, name) => { + const rows = extras[name]; + if (Array.isArray(rows) && rows.length > 0) { + const b = _baseOfLoraString(loraStr); + const eff = normalizeExtraStrengths(rows, lockMap[name] !== false, b.model, b.clip).effective; + if (eff.length > 0) return 1 + eff.length; + } + const mLen = Array.isArray(legacy[name + "_model"]) ? legacy[name + "_model"].length : 0; + const cLen = Array.isArray(legacy[name + "_clip"]) ? legacy[name + "_clip"].length : 0; + if (mLen > 1) return mLen * (cLen > 1 ? cLen : 1); + if (cLen > 1) return cLen; + return 1; + }; + + const entries = []; + loras.forEach(l => { + const parsed = parseLoraString(l); + if (configArray.lora_bypass_states?.[parsed.name]) return; // Skip bypassed + entries.push({ name: parsed.name, variants: parsed.name === "None" ? 1 : variantsFor(l, parsed.name) }); + }); + + const stackable = entries.filter(e => e.name !== "None" && !e.name.endsWith("/")); + if (stackable.length > 1) return stackable.reduce((n, e) => n * e.variants, 1); + + let total = 0; + entries.forEach(e => { + if (e.name === "None" || e.name.endsWith("/*")) { + total += e.variants; // "None" = 1; combined folder = 1 stack + } else if (e.name.endsWith("/")) { + const norm = normalizePath(e.name); + let files; + if (norm === "/" || norm === "") files = availableLoraList ? availableLoraList.length : 1; + else files = availableLoraList ? availableLoraList.filter(al => normalizePath(al).startsWith(norm)).length : 1; + total += files * e.variants; + } else { + total += e.variants; + } + }); + return total || 1; +} + // --- ITERATION COUNT CALCULATION --- export function getIterationCount(configArray) { @@ -547,34 +692,8 @@ export function getIterationCount(configArray) { if (m_count === 0) m_count = 1; // 3. LoRAs - let l_count = 0; - if (!configArray.loras || configArray.loras.length === 0) { - l_count = 1; - } else { - configArray.loras.forEach(l => { - const parsed = parseLoraString(l); - const name = parsed.name; - if (configArray.lora_bypass_states?.[name]) return; // Skip bypassed - if (name === "None") { - l_count += 1; - } else if (name.endsWith("/*")) { - // Combined Folder -> 1 iteration (Single Stack) - l_count += 1; - } else if (name.endsWith("/")) { - // Separate Folder -> N iterations - const norm = normalizePath(name); - if (norm === "/" || norm === "") { - l_count += availableLoras ? availableLoras.length : 1; - } else { - l_count += availableLoras ? availableLoras.filter(al => normalizePath(al).startsWith(norm)).length : 1; - } - } else { - // Single File -> 1 iteration - l_count += 1; - } - }); - } - if (l_count === 0) l_count = 1; + // (Stacks, folders and strength rows: see loraEntryCount.) + const l_count = loraEntryCount(configArray, availableLoras); // 4. VAEs let v_count = 0;