Add ordered Regional LoRA operations

This commit is contained in:
BlackVortexAI
2026-08-23 17:51:20 +02:00
parent 84831dcf3a
commit 9e13d9fb87
14 changed files with 182 additions and 55 deletions
+10
View File
@@ -199,6 +199,16 @@ Unsupported operations are absent from the UI and rejected by the backend. A
transformer always produces a complete valid capability; half-configured runtime
contexts are forbidden.
The dedicated Regional LoRA transformer may persist an ordered list of operation
steps. Every step owns a stable ID, one global or regional target, its operation,
and its own ordered LoRA entries. Targets are repeatable, and drag-and-drop order
is execution order. The backend applies each step through the ordinary registered
capability handler, so one node with `merge(region A)` followed by
`subtract(region B)` is semantically identical to two transformer nodes in that
order. `replace` and `clear` therefore retain their normal capability-wide effect
at their position in the sequence. The original single-operation configuration
remains accepted and is normalized deterministically as the Legacy fallback.
If a downstream configuration references a region removed upstream, that
transformer fails before emitting an output. It reports its node, missing
`document_id + region_id`, and provides an action to open the affected editor.
+25 -25
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -38,6 +38,7 @@ from ..util.regional.lora_v3 import (
build_lora_provider,
resolve_lora_capability,
transform_lora_capability,
transform_lora_sequence,
)
from ..util.regional.sdxl_attention import compile_sdxl_attention, apply_sdxl_attention_patch
from ..util.regional.zimage_attention import compile_zimage_attention, apply_zimage_attention_patch
@@ -177,8 +178,11 @@ class BVRegionalLoraNode:
def transform(self, regional, operation, config_json, resource_provider=None):
payload = json.loads(config_json) if isinstance(config_json, str) else config_json
transformed = transform_lora_capability(regional, payload, registry=LORA_CAPABILITY_REGISTRY, operation=operation)
if operation != "clear" and any(entry["source"]["kind"] == "external" for entry in payload["entries"]):
transformed = transform_lora_sequence(
regional, payload, registry=LORA_CAPABILITY_REGISTRY, fallback_operation=operation
)
result = transformed.capabilities.get(LORA_CAPABILITY)
if result is not None and any(entry["source"]["kind"] == "external" for entry in result["entries"]):
resolve_lora_capability(transformed, resource_provider, registry=LORA_CAPABILITY_REGISTRY)
return (transformed.to_dict(),)
+57
View File
@@ -188,6 +188,63 @@ def transform_lora_capability(value: Any, payload: Any, *, registry: CapabilityR
return context.with_capability(LORA_CAPABILITY, result)
def _same_lora_target(left: dict[str, Any], right: dict[str, Any]) -> bool:
return left.get("scope") == right.get("scope") and (
right.get("scope") == "global"
or (left.get("document_id") == right.get("document_id") and left.get("region_id") == right.get("region_id"))
)
def transform_lora_sequence(
value: Any,
config: Any,
*,
registry: CapabilityRegistry,
fallback_operation: str = "replace",
) -> RegionalContext:
"""Apply a transformer config as one or more ordinary LoRA capability operations."""
if not isinstance(config, dict):
raise RegionalContextError("LoRA transformer config must be an object")
if config.get("version") == 1:
return transform_lora_capability(value, config, registry=registry, operation=fallback_operation)
if config.get("version") != 2 or set(config) != {"version", "collector_id", "entries", "steps"}:
raise RegionalContextError("LoRA transformer config version 2 has unknown or missing fields")
if config["entries"]:
raise RegionalContextError("LoRA transformer config version 2 stores entries inside steps")
steps = config["steps"]
if not isinstance(steps, list):
raise RegionalContextError("LoRA transformer steps must be an array")
context = normalize_context(value, registry=registry)
step_ids: set[str] = set()
for index, step in enumerate(steps):
path = f"LoRA transformer steps[{index}]"
if not isinstance(step, dict) or set(step) != {"id", "operation", "target", "entries"}:
raise RegionalContextError(f"{path} has unknown or missing fields")
step_id = _required_uuid(step["id"], f"{path}.id")
if step_id in step_ids:
raise RegionalContextError(f"duplicate LoRA transformer step id: {step_id}")
step_ids.add(step_id)
operation = step["operation"]
if operation not in {"replace", "merge", "subtract", "clear"}:
raise RegionalContextError(f"{path}.operation is unsupported")
target = step["target"]
if not isinstance(target, dict) or target.get("scope") not in {"global", "region"}:
raise RegionalContextError(f"{path}.target must target global or region scope")
if target["scope"] == "global" and set(target) != {"scope"}:
raise RegionalContextError(f"{path}.target global target has unknown fields")
if target["scope"] == "region":
if set(target) != {"scope", "document_id", "region_id"}:
raise RegionalContextError(f"{path}.target region target has unknown or missing fields")
_required_uuid(target["document_id"], f"{path}.target.document_id")
_required_uuid(target["region_id"], f"{path}.target.region_id")
scoped = {"version": 1, "collector_id": config["collector_id"], "entries": step["entries"]}
validate_lora_capability(scoped)
if any(len(entry["targets"]) != 1 or not _same_lora_target(entry["targets"][0], target) for entry in scoped["entries"]):
raise RegionalContextError(f"{path}.entries must target only the step scope")
context = transform_lora_capability(context, scoped, registry=registry, operation=operation)
return context
def _provider_resources(provider: Any, expected_id: str | None) -> dict[str, Any]:
if expected_id is None:
if provider is not None:
+7
View File
@@ -7,6 +7,7 @@ import {LoraV3ResourcePickerPanel,OptionalLoraV3ScopePicker,withoutLoraV3Target}
import {emptyLoraV3Config,parseLoraV3Config,serializeLoraV3Config} from "../ui/src/regional/loraV3Config.ts";
const collectors=[{id:"collector",label:"Styles",resources:[{id:"skin",label:"Skin"}]}];
const editorSource=readFileSync(new URL("../ui/src/regional/LoraV3EditorWindow.tsx",import.meta.url),"utf8");
test("the production picker renders Collector / Resource from persisted ids",()=>{
const config={version:1,collector_id:"collector",entries:[{id:"entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]}]};
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:true,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}}],onCollector(){},onResource(){},onAddExternal(){}}));
@@ -37,6 +38,12 @@ test("Regional Prompt easy mode renders every stack assigned to its scope",()=>{
const config={version:1,collector_id:"collector",entries:[{id:"first",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]},{id:"second",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]}]};
const html=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{collectors,config,target:{scope:"global"},resolved:true,onCollector(){},onResource(){},onAdd(){},onRemove(){},onClear(){}}));
assert.match(html,/LoRA enabled/);assert.match(html,/LoRA Stack 1/);assert.match(html,/LoRA Stack 2/);assert.match(html,/Add LoRA stack/);
assert.match(html,/aria-label="Remove LoRA Stack 1"/);assert.doesNotMatch(html,/>Remove</);
});
test("the LoRA editor exposes ordered repeatable scope operations and participates in window visibility",()=>{
assert.match(editorSource,/Add step/);assert.match(editorSource,/<SortableList/);assert.match(editorSource,/onReorder=/);
assert.match(editorSource,/label="Target"/);assert.match(editorSource,/label="Operation"/);assert.match(editorSource,/crypto\.randomUUID\(\)/);
assert.match(editorSource,/menuVisible=\{menuVisible\}/);assert.match(editorSource,/onMenuVisible=\{visible=>setWindowMenuVisible/);
});
test("removing one scope preserves shared assignments and the input config",()=>{
const original={version:1,collector_id:"collector",entries:[{id:"shared",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"},{scope:"region",document_id:"doc",region_id:"left"}]}]};
+32
View File
@@ -1,3 +1,5 @@
import json
from pathlib import Path
import unittest
from py.util.regional.context import normalize_context
@@ -8,6 +10,7 @@ from py.util.regional.lora_v3 import (
register_lora_contracts,
resolve_lora_capability,
transform_lora_capability,
transform_lora_sequence,
)
@@ -106,6 +109,35 @@ class RegionalLoraV3Tests(unittest.TestCase):
self.assertNotIn(LORA_CAPABILITY, cleared.capabilities)
self.assertEqual(cleared.core, self.context.core)
def test_scope_steps_match_two_sequential_transformer_nodes(self):
with (Path(__file__).parent / "fixtures" / "regional" / "v1_hybrid_joint.json").open(encoding="utf-8") as handle:
context = normalize_context(json.load(handle), registry=self.capabilities)
first_region, second_region = [region["id"] for region in context.core["regions"][:2]]
collector_id = "22222222-2222-4222-8222-222222222222"
removed_id = "33333333-3333-4333-8333-333333333333"
added_id = "44444444-4444-4444-8444-444444444444"
upstream = transform_lora_capability(context, {"version": 1, "collector_id": collector_id, "entries": [{"id": removed_id, "source": {"kind": "external", "resource_id": "remove"}, "targets": [{"scope": "region", "document_id": context.core["document_id"], "region_id": second_region}]}]}, registry=self.capabilities)
config = {"version": 2, "collector_id": collector_id, "entries": [], "steps": [
{"id": "55555555-5555-4555-8555-555555555555", "operation": "merge", "target": {"scope": "region", "document_id": context.core["document_id"], "region_id": first_region}, "entries": [
{"id": added_id, "source": {"kind": "external", "resource_id": "add"}, "targets": [{"scope": "region", "document_id": context.core["document_id"], "region_id": first_region}]},
]},
{"id": "66666666-6666-4666-8666-666666666666", "operation": "subtract", "target": {"scope": "region", "document_id": context.core["document_id"], "region_id": second_region}, "entries": [
{"id": removed_id, "source": {"kind": "external", "resource_id": "remove"}, "targets": [{"scope": "region", "document_id": context.core["document_id"], "region_id": second_region}]},
]},
]}
transformed = transform_lora_sequence(upstream, config, registry=self.capabilities)
self.assertEqual([entry["id"] for entry in transformed.require_capability(LORA_CAPABILITY)["entries"]], [added_id])
def test_same_region_can_appear_twice_and_order_remains_semantic(self):
target = {"scope": "global"}
entry = {"id": "33333333-3333-4333-8333-333333333333", "source": {"kind": "native", "lora_name": "style.safetensors", "model_strength": 1.0, "clip_strength": 1.0}, "targets": [target]}
config = {"version": 2, "collector_id": None, "entries": [], "steps": [
{"id": "44444444-4444-4444-8444-444444444444", "operation": "merge", "target": target, "entries": [entry]},
{"id": "55555555-5555-4555-8555-555555555555", "operation": "subtract", "target": target, "entries": [entry]},
]}
transformed = transform_lora_sequence(self.context, config, registry=self.capabilities)
self.assertEqual(transformed.require_capability(LORA_CAPABILITY)["entries"], [])
def test_registered_resource_type_rejects_runtime_objects(self):
provider = build_lora_provider("22222222-2222-4222-8222-222222222222", {})
reference = {"provider_id": provider["provider_id"], "resource_type": LORA_RESOURCE_TYPE, "resource_id": "skin"}
+3 -1
View File
@@ -89,7 +89,8 @@ test("toolbar and node-button activation use the same keep-mode lifecycle", () =
test("ComfyUI toolbar exposes a node-aware multi-column window launcher", () => {
assert.match(uiEntry, /<ToolbarWindowLauncher\s+getColumns=\{launcherColumns\}/);
for (const label of ["Regional Prompts", "Detailer Plan", "Detector Registry"]) assert.match(uiEntry, new RegExp(`label:\"${label}\"`));
for (const label of ["Regional Prompts", "Regional LoRA", "Detailer Plan", "Detector Registry"]) assert.match(uiEntry, new RegExp(`label:\"${label}\"`));
assert.match(uiEntry,/workflowNodesOfType\("BV Regional LoRA"\)/);
assert.match(uiEntry,/secondary:\{label:`Quick edit/);
assert.match(toolbarLauncher, /filter\(column=>column\.items\.length\)/);
assert.match(toolbarLauncher, /\.bv-regional-action/);
@@ -108,6 +109,7 @@ test("toolbar primary actions restore the last active node per editor type", ()
assert.match(regionalEditor, /rememberBvWindowInstance\("regional",nextId\)/);
assert.match(uiEntry, /rememberBvWindowInstance\("detailer",scopedNodeKey\(node\)\)/);
assert.match(uiEntry, /rememberBvWindowInstance\("detector",scopedNodeKey\(node\)\)/);
assert.match(uiEntry, /rememberBvWindowInstance\("lora",scopedNodeKey\(node\)\)/);
});
test("detector and detailer editors use shared compact collection and grid modules", () => {
+1 -1
View File
@@ -586,5 +586,5 @@
.lg-node:has([node-type="BV M0 Fake Resource Collector"]) [node-type="BV M0 Fake Resource Collector"]:has(input[aria-label="collector_id"]),.lg-node:has([node-type="BV M0 Fake Resource Collector"]) [node-type="BV M0 Fake Resource Collector"]:has(input[aria-label="resource_a_id"]),.lg-node:has([node-type="BV M0 Fake Resource Collector"]) [node-type="BV M0 Fake Resource Collector"]:has(input[aria-label="resource_b_id"]),.lg-node:has([node-type="BV M0 Fake Resource Consumer"]) [node-type="BV M0 Fake Resource Consumer"]:has(input[aria-label="collector_id"]),.lg-node:has([node-type="BV M0 Fake Resource Consumer"]) [node-type="BV M0 Fake Resource Consumer"]:has(input[aria-label="resource_id"]){display:none}
.lg-slot:has(circle[fill*="BV_RUNTIME_RESOURCE_PROVIDER_M0"],[style*="BV_RUNTIME_RESOURCE_PROVIDER_M0"]){display:none!important}
.lg-slot:has(circle[fill*="BV_RUNTIME_RESOURCE_PROVIDER"],[style*="BV_RUNTIME_RESOURCE_PROVIDER"]){display:none!important}
.bv-lora-v3-picker-host{position:relative;z-index:1;padding:6px}.bv-lora-v3-picker-panel{display:grid;gap:10px}.bv-lora-v3-target{display:grid;gap:8px;padding:9px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-target>h3{margin:0}.bv-optional-lora-scope{display:grid;gap:7px}.bv-lora-v3-enable-row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:12px}.bv-lora-v3-enable-row>.bv-control-field>.bv-control-label{display:none}.bv-lora-v3-enable-row>.bv-control-field{padding-top:20px}.bv-lora-v3-scope-picker{display:grid;gap:8px}.bv-lora-v3-stack-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:end;padding:7px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-stack-row>strong,.bv-lora-v3-stack-row>.bv-resource-picker{grid-column:1}.bv-lora-v3-stack-row>.bv-button{grid-column:2;grid-row:2}.bv-lora-v3-window{display:grid;gap:10px;padding:10px;overflow:auto}
.bv-lora-v3-picker-host{position:relative;z-index:1;padding:6px}.bv-lora-v3-picker-panel{display:grid;gap:10px}.bv-lora-v3-target{display:grid;gap:8px;padding:9px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-target>h3{margin:0}.bv-optional-lora-scope{display:grid;gap:7px}.bv-lora-v3-enable-row{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:start;gap:12px}.bv-lora-v3-enable-row>.bv-control-field>.bv-control-label{display:none}.bv-lora-v3-enable-row>.bv-control-field{padding-top:20px}.bv-lora-v3-enable-row>.bv-control-field .bv-toggle{white-space:nowrap}.bv-lora-v3-scope-picker{display:grid;gap:8px}.bv-lora-v3-stack-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:end;padding:7px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-stack-row>strong,.bv-lora-v3-stack-row>.bv-resource-picker{grid-column:1}.bv-lora-v3-stack-row>.bv-button{grid-column:2;grid-row:2}.bv-trash-icon{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.bv-lora-v3-window{display:grid;gap:10px;padding:10px;overflow:auto}
.lg-node:has([node-type="BV M0 Fake Multi Resource Consumer"]) [node-type="BV M0 Fake Multi Resource Consumer"]:has(input[aria-label="resource_bindings"]){display:none}.bv-m0-multi-picker{max-height:520px;overflow:auto}.bv-m0-binding{display:grid;gap:5px;padding:6px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-m0-binding>header{display:flex;align-items:center;justify-content:space-between}.bv-m0-binding>header button,.bv-m0-add-binding{color:var(--bv-ui-text);background:var(--bv-ui-input);border:1px solid var(--bv-ui-border);border-radius:4px}.bv-m0-binding>header button{width:24px;height:24px}.bv-m0-add-binding{min-height:28px}
+8 -6
View File
@@ -24,7 +24,7 @@ import { visibleExternalDetectorSlots } from "./regional/detectorExternalInputs"
import { DETAILER_UI_NODES, detailerUiLabel } from "./regional/detailerLoopUi";
import { upgradeRemoteLLMProvider } from "./remoteLLM";
import { installM0ResourceSpike } from "./regional/m0ResourceSpike";
import { hideLoraV3Widget, installLoraV3ConsumerSlot, installLoraV3Ui, OPEN_LORA_V3_EDITOR_EVENT } from "./regional/loraV3Ui";
import { hideLoraV3Widget, installLoraV3ConsumerSlot, installLoraV3Ui, LORA_V3_INVENTORY_CHANGED_EVENT, OPEN_LORA_V3_EDITOR_EVENT } from "./regional/loraV3Ui";
import LoraV3EditorWindow from "./regional/LoraV3EditorWindow";
import { installM0CanvasVisibility } from "./regional/m0VisualProjection";
import { applyReducedEffects, applyUiPreferences, applyUiSize, bindWindowSwitchModePersistence, getWindowSwitchMode, setWindowSwitchMode, UI_REDUCED_EFFECTS_SETTING_ID, UI_SIZE_SETTING_ID, UI_WINDOW_SWITCH_MODE_SETTING_ID } from "./ui/preferences";
@@ -350,7 +350,7 @@ const regionalNodes = (): RegionalNode[] => {
return found;
};
const refreshBvToolbarCapabilities=()=>{
const control=workflowNodesOfType("BV Control Center").length>0,quick=regionalNodes().length>0,full=quick||["BV Regional Detailer Plan","BV Detector Registry","BV Smart Pipe","BV Smart Pipe Merge"].some(type=>workflowNodesOfType(type).length>0),overview=full||quick;
const control=workflowNodesOfType("BV Control Center").length>0,quick=regionalNodes().length>0,full=quick||["BV Regional LoRA","BV Regional Detailer Plan","BV Detector Registry","BV Smart Pipe","BV Smart Pipe Merge"].some(type=>workflowNodesOfType(type).length>0),overview=full||quick;
document.body.classList.toggle("bv-toolbar-no-control",!control);document.body.classList.toggle("bv-toolbar-no-editor",!full);document.body.classList.toggle("bv-toolbar-no-quick",!quick);document.body.classList.toggle("bv-toolbar-no-overview",!overview);
window.dispatchEvent(new CustomEvent("bv-toolbar-capabilities-changed",{detail:{control,quick,full,overview}}));
};
@@ -365,9 +365,9 @@ const activateManagedLauncherNode = (type:"detailer"|"detector", prefix:string,
else openTarget();
rememberBvWindowInstance(type,targetId);
};
const openFullEditorNode=(type:string,node:any)=>{if(type==="regional")window.dispatchEvent(new CustomEvent(OPEN_REGIONAL_EDITOR_EVENT,{detail:{node}}));else if(type==="detailer")activateManagedLauncherNode("detailer","detailer-plan","configure_detailer_plan",node);else if(type==="detector")activateManagedLauncherNode("detector","detector-registry","configure_detector_registry",node);else window.dispatchEvent(new CustomEvent("bv-open-smart-pipe-editor",{detail:{node,kind:type}}))};
const openFullEditorNode=(type:string,node:any)=>{if(type==="regional")window.dispatchEvent(new CustomEvent(OPEN_REGIONAL_EDITOR_EVENT,{detail:{node}}));else if(type==="lora")window.dispatchEvent(new CustomEvent(OPEN_LORA_V3_EDITOR_EVENT,{detail:{node}}));else if(type==="detailer")activateManagedLauncherNode("detailer","detailer-plan","configure_detailer_plan",node);else if(type==="detector")activateManagedLauncherNode("detector","detector-registry","configure_detector_registry",node);else window.dispatchEvent(new CustomEvent("bv-open-smart-pipe-editor",{detail:{node,kind:type}}))};
const openLastFullBvEditor=()=>{
const definitions=[{type:"regional",nodes:regionalNodes()},{type:"detailer",nodes:workflowNodesOfType("BV Regional Detailer Plan")},{type:"detector",nodes:workflowNodesOfType("BV Detector Registry")},{type:"pipe",nodes:workflowNodesOfType("BV Smart Pipe")},{type:"merge",nodes:workflowNodesOfType("BV Smart Pipe Merge")}],lastType=lastBvFullWindowType(),lastId=lastType&&lastBvWindowInstance(lastType),last=definitions.find(item=>item.type===lastType)?.nodes.find(node=>scopedNodeKey(node)===lastId);
const definitions=[{type:"regional",nodes:regionalNodes()},{type:"lora",nodes:workflowNodesOfType("BV Regional LoRA")},{type:"detailer",nodes:workflowNodesOfType("BV Regional Detailer Plan")},{type:"detector",nodes:workflowNodesOfType("BV Detector Registry")},{type:"pipe",nodes:workflowNodesOfType("BV Smart Pipe")},{type:"merge",nodes:workflowNodesOfType("BV Smart Pipe Merge")}],lastType=lastBvFullWindowType(),lastId=lastType&&lastBvWindowInstance(lastType),last=definitions.find(item=>item.type===lastType)?.nodes.find(node=>scopedNodeKey(node)===lastId);
if(last){openFullEditorNode(lastType!,last);return}
const fallback=definitions.flatMap(item=>item.nodes.map(node=>({type:item.type,node}))).sort((left,right)=>(Number(left.node.id)||Number.MAX_SAFE_INTEGER)-(Number(right.node.id)||Number.MAX_SAFE_INTEGER))[0];
if(!fallback)return;openFullEditorNode(fallback.type,fallback.node);if(lastType)showBvToast({title:"Previous editor unavailable",message:"Opened the first available BV editor instead.",tone:"warning",duration:4000});
@@ -468,11 +468,12 @@ function BVRoot() {
const [loraStacks, setLoraStacks] = useState<NamedLoraStack[]>([]);
const [loraV3Node,setLoraV3Node]=useState<any|null>(null);
const launcherColumns=useCallback((includeHidden=false):ToolbarLauncherColumn[]=>{
const regional=regionalNodes(),detailers=workflowNodesOfType("BV Regional Detailer Plan"),detectors=workflowNodesOfType("BV Detector Registry"),pipes=[...workflowNodesOfType("BV Smart Pipe"),...workflowNodesOfType("BV Smart Pipe Merge")];
const regional=regionalNodes(),loras=workflowNodesOfType("BV Regional LoRA"),detailers=workflowNodesOfType("BV Regional Detailer Plan"),detectors=workflowNodesOfType("BV Detector Registry"),pipes=[...workflowNodesOfType("BV Smart Pipe"),...workflowNodesOfType("BV Smart Pipe Merge")];
const decorate=(node:any,item:any)=>{const hidden=!windowMenuVisible(node);return hidden&&!includeHidden?null:{...item,hidden,onReveal:hidden?()=>setWindowMenuVisible(node,true):undefined}};
const regionalItems=regional.map(node=>{const copy=nodeLauncherLabel(node,"BV Regional Prompt"),last=lastBvWindowInstance("regional")===scopedNodeKey(node);return decorate(node,{id:scopedNodeKey(node),...copy,meta:`${copy.meta}${last?" · Last active":""}`,onSelect:()=>window.dispatchEvent(new CustomEvent(OPEN_REGIONAL_EDITOR_EVENT,{detail:{node}})),secondary:{label:`Quick edit ${copy.label}`,onSelect:()=>window.dispatchEvent(new CustomEvent(OPEN_REGIONAL_QUICK_EDIT_EVENT,{detail:{node}}))}})}).filter(Boolean) as any[];
return [
{id:"regional",label:"Regional Prompts",items:regionalItems},
{id:"lora",label:"Regional LoRA",items:loras.map(node=>{const copy=nodeLauncherLabel(node,"BV Regional LoRA"),last=lastBvWindowInstance("lora")===scopedNodeKey(node);return decorate(node,{id:scopedNodeKey(node),...copy,meta:`${copy.meta}${last?" · Last active":""}`,onSelect:()=>{rememberBvWindowInstance("lora",scopedNodeKey(node));window.dispatchEvent(new CustomEvent(OPEN_LORA_V3_EDITOR_EVENT,{detail:{node}}));}})}).filter(Boolean) as any[]},
{id:"detailer",label:"Detailer Plan",items:detailers.map(node=>{const copy=nodeLauncherLabel(node,"BV Regional Detailer Plan"),action=node.widgets?.find((widget:any)=>widget.name==="configure_detailer_plan"),last=lastBvWindowInstance("detailer")===scopedNodeKey(node);return decorate(node,{id:scopedNodeKey(node),...copy,disabled:!action||action.disabled,meta:`${copy.meta}${action?.disabled?" · Connect Regional Prompt":last?" · Last active":""}`,onSelect:()=>activateManagedLauncherNode("detailer","detailer-plan","configure_detailer_plan",node)})}).filter(Boolean) as any[]},
{id:"detector",label:"Detector Registry",items:detectors.map(node=>{const copy=nodeLauncherLabel(node,"BV Detector Registry"),last=lastBvWindowInstance("detector")===scopedNodeKey(node);return decorate(node,{id:scopedNodeKey(node),...copy,meta:`${copy.meta}${last?" · Last active":""}`,onSelect:()=>activateManagedLauncherNode("detector","detector-registry","configure_detector_registry",node)})}).filter(Boolean) as any[]},
{id:"smart-pipe",label:"Smart Pipes",items:pipes.map(node=>{const kind=String(node.type??node.comfyClass)==="BV Smart Pipe Merge"?"merge":"pipe",copy=nodeLauncherLabel(node,kind==="merge"?"Smart Pipe Merge":"Smart Pipe");return decorate(node,{id:scopedNodeKey(node),...copy,meta:`${copy.meta} · ${kind==="merge"?"Merge":"Pipe"}`,onSelect:()=>window.dispatchEvent(new CustomEvent("bv-open-smart-pipe-editor",{detail:{node,kind}}))})}).filter(Boolean) as any[]},
@@ -492,8 +493,9 @@ function BVRoot() {
window.addEventListener(OPEN_CONTROL_RACK_EVENT, open);
return () => window.removeEventListener(OPEN_CONTROL_RACK_EVENT, open);
}, []);
useEffect(()=>{const open=(event:Event)=>setLoraV3Node((event as CustomEvent<{node?:any}>).detail?.node??null);window.addEventListener(OPEN_LORA_V3_EDITOR_EVENT,open);return()=>window.removeEventListener(OPEN_LORA_V3_EDITOR_EVENT,open)},[]);
useEffect(()=>{const open=(event:Event)=>{const node=(event as CustomEvent<{node?:any}>).detail?.node??null;if(node)rememberBvWindowInstance("lora",scopedNodeKey(node));setLoraV3Node(node)};window.addEventListener(OPEN_LORA_V3_EDITOR_EVENT,open);return()=>window.removeEventListener(OPEN_LORA_V3_EDITOR_EVENT,open)},[]);
useEffect(()=>{const update=(event:Event)=>setHasControlNodes(Boolean((event as CustomEvent).detail?.control));window.addEventListener("bv-toolbar-capabilities-changed",update);refreshBvToolbarCapabilities();return()=>window.removeEventListener("bv-toolbar-capabilities-changed",update)},[]);
useEffect(()=>{const refresh=()=>refreshBvToolbarCapabilities();window.addEventListener(LORA_V3_INVENTORY_CHANGED_EVENT,refresh);return()=>window.removeEventListener(LORA_V3_INVENTORY_CHANGED_EVENT,refresh)},[]);
useEffect(() => {
const open = (event: Event) => {
+20 -11
View File
@@ -1,20 +1,29 @@
import React, { useEffect, useState } from "react";
import { BvSelect } from "../ui/controls";
import { Button, ResourcePicker, SelectField, SortableList, type SortableItem } from "../ui/components";
import { BvManagedWindow } from "../ui/window";
import { LoraV3ResourcePickerPanel, type LoraV3Config } from "./LoraV3ResourcePickerPanel";
import { addLoraV3TargetEntry, loraV3Catalog, loraV3Resolved, loraV3TargetOptions, readNodeLoraV3Config, removeLoraV3TargetEntry, setLoraV3Collector, setLoraV3EntryResource } from "./loraV3Ui";
import { setWindowMenuVisible, useWindowMenuVisibility } from "../ui/windowRegistry";
import { type LoraV3Config, type LoraV3Operation, type LoraV3Step, loraV3TargetValue } from "./LoraV3ResourcePickerPanel";
import { loraV3Catalog, loraV3Resolved, loraV3TargetOptions, readNodeLoraV3Config, setLoraV3Collector, setLoraV3EntryResource, writeNodeLoraV3Config } from "./loraV3Ui";
const widget=(node:any,name:string)=>node?.widgets?.find((item:any)=>item.name===name);
const operations=["replace","merge","subtract","clear"].map(value=>({value,label:value[0].toUpperCase()+value.slice(1)}));
export default function LoraV3EditorWindow({open,node,onClose}:{open:boolean;node:any|null;onClose:()=>void}){
const [config,setConfig]=useState<LoraV3Config>(()=>readNodeLoraV3Config(node));
const [config,setConfig]=useState<LoraV3Config>(()=>readNodeLoraV3Config(node)),menuVisible=useWindowMenuVisibility(node);
useEffect(()=>{if(open&&node)setConfig(readNodeLoraV3Config(node));},[node,open]);
if(!node)return null;
const collectors=loraV3Catalog(node).filter(item=>item.resources.length>0),resolved=loraV3Resolved(node,config),targetOptions=loraV3TargetOptions(node);
return <BvManagedWindow open={open} title="BV Regional LoRA Editor" initialGeometry={{width:620,height:520}} minSize={{width:420,height:320}} onClose={onClose}>
<div className="bv-ui bv-lora-v3-window">
<BvSelect label="Operation" value={String(widget(node,"operation")?.value??"replace")} onChange={value=>{const item=widget(node,"operation");if(item){item.value=value;item.callback?.(value);node.setDirtyCanvas?.(true,true);}}}><option value="replace">Replace</option><option value="merge">Merge</option><option value="subtract">Subtract</option><option value="clear">Clear</option></BvSelect>
<LoraV3ResourcePickerPanel collectors={collectors} config={config} resolved={resolved} targetOptions={targetOptions} onCollector={id=>setConfig(setLoraV3Collector(node,config,id))} onResource={(entryId,id)=>setConfig(setLoraV3EntryResource(node,config,entryId,id))} onAddExternal={target=>setConfig(addLoraV3TargetEntry(node,config,target))} onRemove={(entryId,target)=>setConfig(removeLoraV3TargetEntry(node,config,entryId,target))}/>
</div>
</BvManagedWindow>;
const collectors=loraV3Catalog(node).filter(item=>item.resources.length>0),targets=loraV3TargetOptions(node),resolved=loraV3Resolved(node,config);
const legacyOperation=String(widget(node,"operation")?.value??"replace") as LoraV3Operation;
const steps:LoraV3Step[]=config.version===2?(config.steps??[]):config.entries.flatMap(entry=>entry.targets.map(target=>({id:crypto.randomUUID(),operation:legacyOperation,target:target as any,entries:[{...entry,targets:[target]}]})));
const commit=(nextSteps:LoraV3Step[],collectorId=config.collector_id)=>{const next:LoraV3Config={version:2,collector_id:collectorId,entries:[],steps:nextSteps};writeNodeLoraV3Config(node,next);setConfig(next);};
const items:SortableItem[]=steps.map((step,index)=>{
const targetValue=loraV3TargetValue(step.target),targetLabel=targets.find(item=>item.value===targetValue)?.label??"Missing region";
return {id:step.id,title:`Step ${index+1}: ${targetLabel}`,description:step.operation,content:<div className="bv-ui-stack">
<SelectField label="Target" value={targetValue} options={targets} onValue={value=>{const target=targets.find(item=>item.value===value)?.target;if(target)commit(steps.map(item=>item.id===step.id?{...item,target,entries:item.entries.map(entry=>({...entry,targets:[target]}))}:item));}}/>
<SelectField label="Operation" value={step.operation} options={operations} onValue={value=>commit(steps.map(item=>item.id===step.id?{...item,operation:value as LoraV3Operation}:item))}/>
{step.operation!=="clear"&&step.entries.map((entry,entryIndex)=>entry.source.kind==="external"?<section className="bv-lora-v3-stack-row" key={entry.id}><strong>LoRA Stack {entryIndex+1}</strong><ResourcePicker collectors={collectors} collectorId={config.collector_id??""} resourceId={entry.source.resource_id} resolved={resolved} onCollector={id=>setConfig(setLoraV3Collector(node,config,id))} onResource={id=>setConfig(setLoraV3EntryResource(node,config,entry.id,id))}/><Button intent="ghost" iconOnly aria-label={`Remove LoRA Stack ${entryIndex+1}`} onClick={()=>commit(steps.map(item=>item.id===step.id?{...item,entries:item.entries.filter(candidate=>candidate.id!==entry.id)}:item))}>×</Button></section>:null)}
{step.operation!=="clear"&&<Button intent="secondary" disabled={!collectors.length} onClick={()=>{const choice=collectors.find(item=>item.id===config.collector_id)??collectors[0],resource=choice?.resources[0];if(resource)commit(steps.map(item=>item.id===step.id?{...item,entries:[...item.entries,{id:crypto.randomUUID(),source:{kind:"external",resource_id:resource.id},targets:[item.target]}]}:item),choice.id);}}>Add LoRA stack</Button>}
</div>};
});
return <BvManagedWindow open={open} title="BV Regional LoRA Editor" shortTitle="Regional LoRA" menuVisible={menuVisible} onMenuVisible={visible=>setWindowMenuVisible(node,visible)} initialGeometry={{width:620,height:520}} minSize={{width:420,height:320}} onClose={onClose}><div className="bv-ui bv-lora-v3-window"><Button intent="secondary" disabled={!targets.length} onClick={()=>commit([...steps,{id:crypto.randomUUID(),operation:"merge",target:targets[0].target,entries:[]}])}>Add step</Button><SortableList items={items} initialExpanded={items.map(item=>item.id)} onReorder={ordered=>{const byId=new Map(steps.map(step=>[step.id,step]));commit(ordered.map(item=>byId.get(item.id)!).filter(Boolean));}} onRemove={id=>commit(steps.filter(step=>step.id!==id))}/></div></BvManagedWindow>;
}
@@ -2,7 +2,9 @@ import React, { useEffect, useState } from "react";
import { Button, ResourcePicker, ResourcePickerCollector, ToggleField, type SelectOption } from "../ui/components";
export type LoraV3Entry={id:string;source:{kind:"external";resource_id:string}|{kind:"native";lora_name:string;model_strength:number;clip_strength:number};targets:Record<string,unknown>[]};
export type LoraV3Config={version:1;collector_id:string|null;entries:LoraV3Entry[]};
export type LoraV3Operation="replace"|"merge"|"subtract"|"clear";
export type LoraV3Step={id:string;operation:LoraV3Operation;target:LoraV3Target;entries:LoraV3Entry[]};
export type LoraV3Config={version:1|2;collector_id:string|null;entries:LoraV3Entry[];steps?:LoraV3Step[]};
export type LoraV3Target={scope:"global"}|{scope:"region";document_id:string;region_id:string};
export type LoraV3TargetOption=SelectOption&{target:LoraV3Target};
@@ -17,7 +19,7 @@ const externalFor=(config:LoraV3Config,target:LoraV3Target)=>config.entries.filt
export function LoraV3ScopePicker({collectors,config,target,resolved,onCollector,onResource,onAdd,onRemove}:ScopeProps){
const entries=externalFor(config,target);
return <div className="bv-lora-v3-scope-picker">{entries.map((entry,index)=><section className="bv-lora-v3-stack-row" key={entry.id}><strong>LoRA Stack {index+1}</strong><ResourcePicker collectors={collectors} collectorId={config.collector_id??""} resourceId={entry.source.resource_id} resolved={resolved} onCollector={onCollector} onResource={id=>onResource(entry.id,id)}/><Button density="compact" intent="ghost" onClick={()=>onRemove(entry.id,target)}>Remove</Button></section>)}<Button intent="secondary" disabled={!collectors.some(collector=>collector.resources.length>0)} onClick={()=>onAdd(target)}>Add LoRA stack</Button></div>;
return <div className="bv-lora-v3-scope-picker">{entries.map((entry,index)=><section className="bv-lora-v3-stack-row" key={entry.id}><strong>LoRA Stack {index+1}</strong><ResourcePicker collectors={collectors} collectorId={config.collector_id??""} resourceId={entry.source.resource_id} resolved={resolved} onCollector={onCollector} onResource={id=>onResource(entry.id,id)}/><Button density="compact" intent="ghost" iconOnly aria-label={`Remove LoRA Stack ${index+1}`} title="Remove LoRA stack" onClick={()=>onRemove(entry.id,target)}><svg className="bv-trash-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></Button></section>)}<Button intent="secondary" disabled={!collectors.some(collector=>collector.resources.length>0)} onClick={()=>onAdd(target)}>Add LoRA stack</Button></div>;
}
export function OptionalLoraV3ScopePicker(props:ScopeProps&{onClear:(target:LoraV3Target)=>void}){
+1 -1
View File
@@ -4,7 +4,7 @@ export const emptyLoraV3Config=():LoraV3Config=>({version:1,collector_id:null,en
export function parseLoraV3Config(value:unknown):LoraV3Config{
const candidate=typeof value==="string"?JSON.parse(value):value;
if(!candidate||typeof candidate!=="object"||(candidate as any).version!==1||!Array.isArray((candidate as any).entries))throw new Error("Invalid BV Regional LoRA v3 configuration");
if(!candidate||typeof candidate!=="object"||![1,2].includes((candidate as any).version)||!Array.isArray((candidate as any).entries)||((candidate as any).version===2&&!Array.isArray((candidate as any).steps)))throw new Error("Invalid BV Regional LoRA v3 configuration");
return structuredClone(candidate as LoraV3Config);
}
+7 -5
View File
@@ -6,6 +6,7 @@ import { connectLoraConsumerTree, ensureLoraCollectorOutput, ensureLoraConsumerI
import { installM0CanvasVisibility } from "./m0VisualProjection";
export const OPEN_LORA_V3_EDITOR_EVENT="bv-open-regional-lora-editor";
export const LORA_V3_INVENTORY_CHANGED_EVENT="bv-regional-lora-inventory-changed";
const widget=(node:any,name:string)=>node.widgets?.find((item:any)=>item.name===name);
const nodeClass=(node:any)=>String(node?.comfyClass??node?.type??"");
const graphLink=(graph:any,id:any)=>id==null?null:(graph?._links?.get?.(id)??graph?.links?.get?.(id)??graph?.links?.[id]??null);
@@ -25,7 +26,8 @@ export function readNodeLoraV3Config(node:any,name=nodeClass(node)==="BV Regiona
try{
let config=parseLoraV3Config(widget(node,name)?.value),linked=linkedLocalLoraCollector(node);
const collectorRemap=linked?.__bvLoraCollectorIdRemap?.[String(config.collector_id??"")],resourceRemaps=Object.assign({},...upstreamNamedStacks(linked).map((item:any)=>item.node?.__bvLoraResourceIdRemap??{}));
if(collectorRemap){config={...config,collector_id:collectorRemap,entries:config.entries.map(entry=>entry.source.kind==="external"&&resourceRemaps[entry.source.resource_id]?{...entry,source:{...entry.source,resource_id:resourceRemaps[entry.source.resource_id]}}:entry)};writeNodeLoraV3Config(node,config,name);}
const remap=(entry:any)=>entry.source.kind==="external"&&resourceRemaps[entry.source.resource_id]?{...entry,source:{...entry.source,resource_id:resourceRemaps[entry.source.resource_id]}}:entry;
if(collectorRemap){config={...config,collector_id:collectorRemap,entries:config.entries.map(remap),steps:config.steps?.map(step=>({...step,entries:step.entries.map(remap)}))};writeNodeLoraV3Config(node,config,name);}
return config;
}catch{return emptyLoraV3Config();}
}
@@ -40,8 +42,8 @@ export function loraV3Resolved(node:any,config:LoraV3Config){const linked=linked
function commitLoraV3Config(node:any,next:LoraV3Config){
const choice=loraV3Catalog(node).find(item=>item.id===next.collector_id);connectLoraConsumerTree(node,choice?.node??null);writeNodeLoraV3Config(node,next);return next;
}
export function setLoraV3Collector(node:any,config:LoraV3Config,collectorId:string){const choice=loraV3Catalog(node).find(item=>item.id===collectorId),resourceId=choice?.resources[0]?.id??"";return commitLoraV3Config(node,{...config,collector_id:collectorId||null,entries:config.entries.map(entry=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry)});}
export function setLoraV3EntryResource(node:any,config:LoraV3Config,entryId:string,resourceId:string){return commitLoraV3Config(node,{...config,entries:config.entries.map(entry=>entry.id===entryId&&entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry)});}
export function setLoraV3Collector(node:any,config:LoraV3Config,collectorId:string){const choice=loraV3Catalog(node).find(item=>item.id===collectorId),resourceId=choice?.resources[0]?.id??"",map=(entry:any)=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry;return commitLoraV3Config(node,{...config,collector_id:collectorId||null,entries:config.entries.map(map),steps:config.steps?.map(step=>({...step,entries:step.entries.map(map)}))});}
export function setLoraV3EntryResource(node:any,config:LoraV3Config,entryId:string,resourceId:string){const map=(entry:any)=>entry.id===entryId&&entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry;return commitLoraV3Config(node,{...config,entries:config.entries.map(map),steps:config.steps?.map(step=>({...step,entries:step.entries.map(map)}))});}
export function addLoraV3TargetEntry(node:any,config:LoraV3Config,target:LoraV3Target){const choice=loraV3Catalog(node).find(item=>item.id===config.collector_id)??loraV3Catalog(node)[0],resource=choice?.resources[0];if(!choice||!resource)return config;return commitLoraV3Config(node,{...config,collector_id:choice.id,entries:[...config.entries,{id:crypto.randomUUID(),source:{kind:"external",resource_id:resource.id},targets:[target]}]});}
export function removeLoraV3TargetEntry(node:any,config:LoraV3Config,entryId:string,target:LoraV3Target){return commitLoraV3Config(node,withoutLoraV3Target(config,target,entryId));}
export function clearLoraV3Target(node:any,config:LoraV3Config,target:LoraV3Target){return commitLoraV3Config(node,withoutLoraV3Target(config,target));}
@@ -52,6 +54,6 @@ function installTransformer(node:any){installLoraV3ConsumerSlot(node);hideLoraV3
export function installLoraV3Ui(nodeType:any,nodeData:any){
const collector=nodeData.name==="BV LoRA Stack Collector",transformer=nodeData.name==="BV Regional LoRA",consumer=["BV Regional Native Conditioning","BV Regional Krea 2 Attention","BV Regional Anima Conditioning"].includes(nodeData.name);if(!collector&&!transformer&&!consumer)return false;
const created=nodeType.prototype.onNodeCreated,configured=nodeType.prototype.onConfigure,changed=nodeType.prototype.onConnectionsChange,upgrade=function(this:any){if(collector)installCollector(this);if(transformer)installTransformer(this);if(consumer)installLoraV3ConsumerSlot(this);};
nodeType.prototype.onNodeCreated=function(){const result=created?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};nodeType.prototype.onConfigure=function(){const result=configured?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};nodeType.prototype.onConnectionsChange=function(){const result=changed?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};return true;
const created=nodeType.prototype.onNodeCreated,configured=nodeType.prototype.onConfigure,changed=nodeType.prototype.onConnectionsChange,removed=nodeType.prototype.onRemoved,upgrade=function(this:any){if(collector)installCollector(this);if(transformer)installTransformer(this);if(consumer)installLoraV3ConsumerSlot(this);if(transformer)window.dispatchEvent(new CustomEvent(LORA_V3_INVENTORY_CHANGED_EVENT));};
nodeType.prototype.onNodeCreated=function(){const result=created?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};nodeType.prototype.onConfigure=function(){const result=configured?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};nodeType.prototype.onConnectionsChange=function(){const result=changed?.apply(this,arguments);queueMicrotask(()=>upgrade.call(this));return result;};nodeType.prototype.onRemoved=function(){const result=removed?.apply(this,arguments);if(transformer)queueMicrotask(()=>window.dispatchEvent(new CustomEvent(LORA_V3_INVENTORY_CHANGED_EVENT)));return result;};return true;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type BvWindowType = "regional" | "quick" | "detailer" | "detector" | "pipe" | "merge";
export type BvWindowType = "regional" | "quick" | "lora" | "detailer" | "detector" | "pipe" | "merge";
const lastActive = new Map<BvWindowType, string>();
let lastFull:BvWindowType|undefined;