Add Regional v3 LoRA vertical slice

This commit is contained in:
BlackVortexAI
2026-08-23 16:24:37 +02:00
parent 24ccdef81a
commit 28ab83f36a
15 changed files with 772 additions and 35 deletions
+25 -25
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -20,6 +20,7 @@
"BV M0 Fake Resource Consumer": "Technical BV Regional v3 feasibility consumer for validating strict resource identity and hidden-link behavior.",
"BV M0 Fake Multi Resource Consumer": "Technical BV Regional v3 feasibility consumer for validating native multi-collector fan-in and hidden-link behavior.",
"BV Named LoRA Stack": "Name an external LoRA stack and add it to a chainable regional stack registry.",
"BV LoRA Stack Collector": "Expose live named LoRA stacks through the typed BV Regional v3 resource-provider contract.",
"BV Pipe": "Carry configurable named values through a typed workflow pipe.",
"BV Pipe Config": "Define the configurable slots used by a BV Pipe.",
"BV Prompt AST Debug": "Inspect a structured BV prompt AST as readable JSON.",
@@ -39,6 +40,7 @@
"BV Regional Image Send": "Send a preview image to a selected Regional Editor without saving it.",
"BV Detailer Loop Detect to SEGS (Impact)": "Resolve the current job's detector binding inside the region crop and return mask-gated Impact SEGS.",
"BV Regional Mask Render": "Render the mask for a selected region from a BV Regional document.",
"BV Regional LoRA": "Add or replace the immutable LoRA capability in a BV Regional v3 context.",
"BV Regional Native Conditioning": "Compile a BV Regional document into native ComfyUI masked conditioning.",
"BV Regional SDXL Attention": "Route BV Regional prompts through SDXL cross-attention for Illustrious, Pony and compatible SDXL models.",
"BV Regional Z-Image Attention": "Route BV Regional prompts through Z-Image Turbo joint attention with a standard KSampler.",
+80 -4
View File
@@ -26,10 +26,19 @@ from ..util.regional.lora_hooks import (
apply_attention_hook_passes,
create_hook_groups,
default_bindings,
parse_registry,
reconcile_bindings,
resolve_stack_paths,
resolve_scope_stacks,
)
from ..util.regional.lora_v3 import (
LORA_CAPABILITY,
LORA_CAPABILITY_REGISTRY,
RUNTIME_PROVIDER,
build_lora_provider,
resolve_lora_capability,
transform_lora_capability,
)
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
from ..util.regional.flux2_klein_attention import (
@@ -113,6 +122,64 @@ class BVNamedLoraStackNode:
return (add_named_stack(registry, stack_id, name, lora_stack),)
class BVLoraStackCollectorNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_registry": (REGISTRY, {}),
"collector_id": ("STRING", {"default": "", "multiline": False}),
}
}
RETURN_TYPES = (RUNTIME_PROVIDER,)
RETURN_NAMES = ("resource_provider",)
FUNCTION = "collect"
CATEGORY = CATEGORY_CORE
DESCRIPTION = "Exposes live named LoRA stacks through the typed BV v3 runtime-resource protocol."
def collect(self, lora_registry, collector_id):
stacks = parse_registry(lora_registry)["stacks"]
return (build_lora_provider(collector_id, stacks),)
class BVRegionalLoraNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"regional": (REGIONAL, {}),
"operation": (["replace", "merge", "subtract", "clear"], {"default": "replace"}),
"config_json": ("STRING", {"default": '{"version":1,"collector_id":null,"entries":[]}', "multiline": True}),
},
"optional": {
"resource_provider": (RUNTIME_PROVIDER, {"forceInput": True}),
},
}
RETURN_TYPES = (REGIONAL,)
RETURN_NAMES = ("regional",)
FUNCTION = "transform"
CATEGORY = CATEGORY_CORE
DESCRIPTION = "Adds or replaces the immutable BV Regional v3 LoRA capability."
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"]):
resolve_lora_capability(transformed, resource_provider, registry=LORA_CAPABILITY_REGISTRY)
return (transformed.to_dict(),)
def _consumer_lora_scopes(regional, document, lora_registry=None, lora_bindings=None, resource_provider=None):
if lora_registry is not None or lora_bindings is not None:
return resolve_scope_stacks(lora_registry, lora_bindings, document)
context = normalize_context(regional, registry=LORA_CAPABILITY_REGISTRY)
if LORA_CAPABILITY not in context.capabilities:
return {}
return resolve_lora_capability(context, resource_provider, registry=LORA_CAPABILITY_REGISTRY)
class BVRegionalDebugNode:
@classmethod
def INPUT_TYPES(cls):
@@ -374,6 +441,7 @@ class BVRegionalNativeConditioningNode:
"optional": {
"lora_registry": (REGISTRY, {}),
"lora_bindings": (BINDINGS, {}),
"resource_provider": (RUNTIME_PROVIDER, {"forceInput": True}),
},
}
@@ -386,10 +454,10 @@ class BVRegionalNativeConditioningNode:
"mask_bounds requires a 2D image latent and is rejected for Anima with a compatibility error."
)
def compile(self, regional, clip, region_strength_multiplier=1.0, native_composition="blend", hybrid_blend_ratio=0.35, lora_registry=None, lora_bindings=None):
def compile(self, regional, clip, region_strength_multiplier=1.0, native_composition="blend", hybrid_blend_ratio=0.35, lora_registry=None, lora_bindings=None, resource_provider=None):
document = context_document(regional)
scope_stacks = resolve_stack_paths(
resolve_scope_stacks(lora_registry, lora_bindings, document)
_consumer_lora_scopes(regional, document, lora_registry, lora_bindings, resource_provider)
)
hook_groups = create_hook_groups(scope_stacks)
return compile_native_conditioning(
@@ -527,6 +595,7 @@ class BVRegionalKrea2AttentionNode:
"optional": {
"lora_registry": (REGISTRY, {}),
"lora_bindings": (BINDINGS, {}),
"resource_provider": (RUNTIME_PROVIDER, {"forceInput": True}),
},
}
@@ -554,12 +623,13 @@ class BVRegionalKrea2AttentionNode:
regional_lora_mode="token_gated_singlepass",
lora_registry=None,
lora_bindings=None,
resource_provider=None,
):
if regional_lora_mode not in {"multipass_legacy", "token_gated_singlepass"}:
raise ValueError("regional_lora_mode must be multipass_legacy or token_gated_singlepass")
document = context_document(regional)
scope_stacks = resolve_stack_paths(
resolve_scope_stacks(lora_registry, lora_bindings, document)
_consumer_lora_scopes(regional, document, lora_registry, lora_bindings, resource_provider)
)
hook_groups = create_hook_groups(scope_stacks)
positive, negative, slots, aspect_ratio = compile_krea2_attention(
@@ -624,6 +694,7 @@ class BVRegionalAnimaConditioningNode:
"optional": {
"lora_registry": (REGISTRY, {}),
"lora_bindings": (BINDINGS, {}),
"resource_provider": (RUNTIME_PROVIDER, {"forceInput": True}),
},
}
@@ -653,6 +724,7 @@ class BVRegionalAnimaConditioningNode:
regional_lora_mode="multipass_legacy",
lora_registry=None,
lora_bindings=None,
resource_provider=None,
):
if regional_lora_mode not in {"multipass_legacy", "token_gated_singlepass"}:
raise ValueError("regional_lora_mode must be multipass_legacy or token_gated_singlepass")
@@ -666,7 +738,7 @@ class BVRegionalAnimaConditioningNode:
document = context_document(regional)
scope_stacks = resolve_stack_paths(
resolve_scope_stacks(lora_registry, lora_bindings, document)
_consumer_lora_scopes(regional, document, lora_registry, lora_bindings, resource_provider)
)
hook_groups = create_hook_groups(scope_stacks)
positive, negative, regions, background = compile_anima_adapter(document, clip, hook_groups)
@@ -829,6 +901,8 @@ class BVRegionalImageSaveNode(_BVRegionalImageTargetMixin, SaveImage):
NODE_CLASS_MAPPINGS = {
"BV Regional Prompt": BVRegionalPromptNode,
"BV Named LoRA Stack": BVNamedLoraStackNode,
"BV LoRA Stack Collector": BVLoraStackCollectorNode,
"BV Regional LoRA": BVRegionalLoraNode,
"BV Regional Debug": BVRegionalDebugNode,
"BV Regional Select": BVRegionalSelectNode,
"BV Regional Deconstructor": BVRegionalDeconstructorNode,
@@ -851,6 +925,8 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"BV Regional Prompt": "🌀 BV Regional Prompt",
"BV Named LoRA Stack": "🌀 BV Named LoRA Stack",
"BV LoRA Stack Collector": "🌀 BV LoRA Stack Collector",
"BV Regional LoRA": "🌀 BV Regional LoRA",
"BV Regional Debug": "🌀 BV Regional Debug",
"BV Regional Select": "🌀 BV Regional Select",
"BV Regional Deconstructor": "🌀 BV Regional Deconstructor",
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
import math
import re
from typing import Any
from .context import (
CapabilityRegistration,
CapabilityRegistry,
RegionalContext,
RegionalContextError,
ResourceRegistration,
ResourceRegistry,
normalize_context,
)
from .lora_hooks import parse_registry
LORA_CAPABILITY = "bv-nodepack.lora"
LORA_RESOURCE_TYPE = "bv-nodepack.lora-stack"
RUNTIME_PROVIDER = "BV_RUNTIME_RESOURCE_PROVIDER"
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.IGNORECASE)
def _required_uuid(value: Any, path: str) -> str:
clean = str(value or "").strip()
if not _UUID.fullmatch(clean):
raise RegionalContextError(f"{path} must be a UUID")
return clean
def _finite_number(value: Any, path: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise RegionalContextError(f"{path} must be a finite number")
return float(value)
def validate_lora_resource_reference(reference: dict[str, Any]) -> None:
if set(reference) != {"provider_id", "resource_type", "resource_id"}:
raise RegionalContextError("LoRA resource reference has unknown or missing fields")
_required_uuid(reference["provider_id"], "LoRA resource provider_id")
if reference["resource_type"] != LORA_RESOURCE_TYPE:
raise RegionalContextError(f"LoRA resource_type must be {LORA_RESOURCE_TYPE!r}")
if not isinstance(reference["resource_id"], str) or not reference["resource_id"].strip():
raise RegionalContextError("LoRA resource_id must be a non-empty string")
def validate_lora_capability(payload: dict[str, Any]) -> None:
if set(payload) != {"version", "collector_id", "entries"}:
raise RegionalContextError("LoRA capability has unknown or missing fields")
if payload["version"] != 1:
raise RegionalContextError("LoRA capability version must be 1")
collector_id = payload["collector_id"]
if collector_id is not None:
_required_uuid(collector_id, "LoRA collector_id")
entries = payload["entries"]
if not isinstance(entries, list):
raise RegionalContextError("LoRA capability entries must be an array")
seen: set[str] = set()
needs_collector = False
for index, entry in enumerate(entries):
path = f"LoRA entries[{index}]"
if not isinstance(entry, dict) or set(entry) != {"id", "source", "targets"}:
raise RegionalContextError(f"{path} has unknown or missing fields")
entry_id = _required_uuid(entry["id"], f"{path}.id")
if entry_id in seen:
raise RegionalContextError(f"duplicate LoRA entry id: {entry_id}")
seen.add(entry_id)
source = entry["source"]
if not isinstance(source, dict):
raise RegionalContextError(f"{path}.source must be an object")
if source.get("kind") == "external":
if set(source) != {"kind", "resource_id"} or not isinstance(source["resource_id"], str) or not source["resource_id"].strip():
raise RegionalContextError(f"{path}.source is not a valid external LoRA reference")
needs_collector = True
elif source.get("kind") == "native":
if set(source) != {"kind", "lora_name", "model_strength", "clip_strength"}:
raise RegionalContextError(f"{path}.source is not a valid native LoRA entry")
if not isinstance(source["lora_name"], str) or not source["lora_name"].strip():
raise RegionalContextError(f"{path}.source.lora_name must be non-empty")
_finite_number(source["model_strength"], f"{path}.source.model_strength")
_finite_number(source["clip_strength"], f"{path}.source.clip_strength")
else:
raise RegionalContextError(f"{path}.source.kind must be native or external")
targets = entry["targets"]
if not isinstance(targets, list) or not targets:
raise RegionalContextError(f"{path}.targets must be a non-empty array")
for target_index, target in enumerate(targets):
target_path = f"{path}.targets[{target_index}]"
if not isinstance(target, dict) or target.get("scope") not in {"global", "region"}:
raise RegionalContextError(f"{target_path} must target global or region scope")
if target["scope"] == "global" and set(target) != {"scope"}:
raise RegionalContextError(f"{target_path} global target has unknown fields")
if target["scope"] == "region":
if set(target) != {"scope", "document_id", "region_id"}:
raise RegionalContextError(f"{target_path} region target has unknown or missing fields")
_required_uuid(target["document_id"], f"{target_path}.document_id")
_required_uuid(target["region_id"], f"{target_path}.region_id")
if needs_collector and collector_id is None:
raise RegionalContextError("external LoRA entries require collector_id")
def _replace_lora(_current: dict[str, Any], configured: Any) -> dict[str, Any]:
return dict(configured)
def _merge_lora(current: dict[str, Any], configured: Any) -> dict[str, Any]:
incoming = dict(configured)
if current.get("collector_id") != incoming.get("collector_id"):
raise RegionalContextError("LoRA merge requires the same collector_id")
incoming_ids = {entry["id"] for entry in incoming["entries"]}
return {**incoming, "entries": [entry for entry in current["entries"] if entry["id"] not in incoming_ids] + incoming["entries"]}
def _subtract_lora(current: dict[str, Any], configured: Any) -> dict[str, Any]:
incoming = dict(configured)
if current.get("collector_id") != incoming.get("collector_id"):
raise RegionalContextError("LoRA subtract requires the same collector_id")
removed = {entry["id"] for entry in incoming["entries"]}
return {**current, "entries": [entry for entry in current["entries"] if entry["id"] not in removed]}
def register_lora_contracts() -> tuple[CapabilityRegistry, ResourceRegistry]:
capabilities = CapabilityRegistry()
resources = ResourceRegistry()
capabilities.register(
"bv-nodepack", "lora",
CapabilityRegistration(
version=1,
validator=validate_lora_capability,
operations={"replace": _replace_lora, "merge": _merge_lora, "subtract": _subtract_lora, "clear": lambda current, _configured: current},
metadata={"display_name": "LoRA"},
),
source=__name__,
)
resources.register(
"bv-nodepack", "lora-stack",
ResourceRegistration(validator=validate_lora_resource_reference, metadata={"display_name": "LoRA Stack"}),
source=__name__,
)
return capabilities, resources
LORA_CAPABILITY_REGISTRY, LORA_RESOURCE_REGISTRY = register_lora_contracts()
def build_lora_provider(provider_id: str, stacks: Any) -> dict[str, Any]:
clean_id = _required_uuid(provider_id, "LoRA provider_id")
registry = parse_registry({"schema": "bv.lora_stack_registry", "version": 1, "stacks": stacks})
return {
"schema": "bv.runtime_resource_provider",
"version": 1,
"provider_id": clean_id,
"resource_type": LORA_RESOURCE_TYPE,
"resources": registry["stacks"],
}
def transform_lora_capability(value: Any, payload: Any, *, registry: CapabilityRegistry, operation: str = "replace") -> RegionalContext:
context = normalize_context(value, registry=registry)
if operation == "clear":
return context.without_capability(LORA_CAPABILITY)
clean = dict(payload)
validate_lora_capability(clean)
document_id = context.core["document_id"]
region_ids = {region["id"] for region in context.core["regions"]}
for entry in clean["entries"]:
for target in entry["targets"]:
if target["scope"] == "region" and (
target["document_id"] != document_id or target["region_id"] not in region_ids
):
raise RegionalContextError(
"LoRA target references a missing region "
f"{target['document_id']} + {target['region_id']}"
)
current = context.capabilities.get(LORA_CAPABILITY)
if current is None:
if operation not in {"replace", "merge"}:
raise RegionalContextError(f"cannot {operation} a missing LoRA capability")
result = clean
else:
registration = registry.get(LORA_CAPABILITY)
handler = registration.operations.get(operation) if registration else None
if handler is None:
raise RegionalContextError(f"unsupported LoRA operation: {operation}")
result = handler(current, clean)
validate_lora_capability(result)
return context.with_capability(LORA_CAPABILITY, result)
def _provider_resources(provider: Any, expected_id: str | None) -> dict[str, Any]:
if expected_id is None:
if provider is not None:
raise RegionalContextError("LoRA provider is connected but the capability has no collector_id")
return {}
if not isinstance(provider, dict):
raise RegionalContextError("LoRA collector is missing; collector and consumer must be in the same graph")
if provider.get("schema") != "bv.runtime_resource_provider" or provider.get("version") != 1:
raise RegionalContextError("LoRA runtime resource provider is invalid")
if provider.get("resource_type") != LORA_RESOURCE_TYPE:
raise RegionalContextError("LoRA runtime resource provider has the wrong resource type")
if provider.get("provider_id") != expected_id:
raise RegionalContextError(
f"LoRA collector is missing or mismatched: expected {expected_id!r}, received {provider.get('provider_id')!r}"
)
resources = provider.get("resources")
if not isinstance(resources, dict):
raise RegionalContextError("LoRA runtime resource provider resources must be an object")
return resources
def resolve_lora_capability(value: Any, provider: Any = None, *, registry: CapabilityRegistry) -> dict[str, list[list[Any]]]:
context = normalize_context(value, registry=registry)
payload = context.require_capability(LORA_CAPABILITY)
resources = _provider_resources(provider, payload["collector_id"])
direct: dict[str, list[list[Any]]] = {"global": []}
for entry in payload["entries"]:
source = entry["source"]
if source["kind"] == "native":
stack = [[source["lora_name"], float(source["model_strength"]), float(source["clip_strength"])]]
else:
resource = resources.get(source["resource_id"])
if not isinstance(resource, dict):
raise RegionalContextError(
f"LoRA resource is unresolved: {source['resource_id']!r} in collector {payload['collector_id']!r}"
)
stack = parse_registry({"schema": "bv.lora_stack_registry", "version": 1, "stacks": {source["resource_id"]: resource}})["stacks"][source["resource_id"]]["stack"]
for target in entry["targets"]:
key = "global" if target["scope"] == "global" else target["region_id"]
direct.setdefault(key, []).extend([list(item) for item in stack])
global_entries = direct.pop("global")
scopes: dict[str, list[list[Any]]] = {}
if global_entries:
scopes["global"] = [list(item) for item in global_entries]
scopes["background"] = [list(item) for item in global_entries]
for region in context.core["regions"]:
combined = [list(item) for item in global_entries]
combined.extend(direct.get(region["id"], []))
combined = [item for item in combined if item[1] != 0.0 or item[2] != 0.0]
if combined:
scopes[region["id"]] = combined
return scopes
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";
import {connectLocalLoraCollector,connectLoraConsumerTree,downstreamLoraConsumers,ensureLoraCollectorOutput,ensureLoraConsumerInput,linkedLocalLoraCollector,localLoraCollectors,upstreamLoraTransformer} from "../ui/src/regional/loraV3Graph.ts";
const graph=()=>({_nodes:[],links:new Map(),getNodeById(id){return this._nodes.find(node=>node.id===id)}});
const node=(id,type,g)=>({id,type,graph:g,inputs:[],outputs:[],addInput(name,type){this.inputs.push({name,type,link:null})},addOutput(name,type){this.outputs.push({name,type,links:null})}});
test("production LoRA slots use the permanent typed provider contract",()=>{
const g=graph(),collector=node(1,"BV LoRA Stack Collector",g),consumer=node(2,"BV Regional LoRA",g);
assert.equal(ensureLoraCollectorOutput(collector),0);assert.equal(ensureLoraConsumerInput(consumer),0);
assert.equal(collector.outputs[0].type,"BV_RUNTIME_RESOURCE_PROVIDER");assert.equal(consumer.inputs[0].hidden,true);
});
test("collector connections are ordinary same-graph links",()=>{
const g=graph(),collector=node(1,"BV LoRA Stack Collector",g),consumer=node(2,"BV Regional LoRA",g);g._nodes.push(collector,consumer);
collector.connect=function(output,target,input){const id=7;g.links.set(id,{origin_id:this.id,origin_slot:output,target_id:target.id,target_slot:input});this.outputs[output].links=[id];target.inputs[input].link=id};
assert.equal(connectLocalLoraCollector(consumer,collector),true);assert.equal(linkedLocalLoraCollector(consumer),collector);
});
test("cross-graph collectors are rejected without fallback",()=>{
const local=graph(),external=graph(),collector=node(1,"BV LoRA Stack Collector",external),consumer=node(2,"BV Regional LoRA",local);
local._nodes.push(consumer);external._nodes.push(collector);
assert.equal(connectLocalLoraCollector(consumer,collector),false);assert.equal(linkedLocalLoraCollector(consumer),null);
});
test("picker catalog is restricted to the concrete consumer graph",()=>{
const local=graph(),external=graph(),first=node(1,"BV LoRA Stack Collector",local),second=node(2,"BV LoRA Stack Collector",external),consumer=node(3,"BV Regional LoRA",local);
local._nodes.push(first,consumer);external._nodes.push(second);
assert.deepEqual(localLoraCollectors(consumer),[first]);
});
test("one picker selection materializes links to downstream LoRA executors",()=>{
const g=graph(),collector=node(1,"BV LoRA Stack Collector",g),transformer=node(2,"BV Regional LoRA",g),consumer=node(3,"BV Regional Native Conditioning",g);g._nodes.push(collector,transformer,consumer);
transformer.outputs=[{type:"BV_REGIONAL",links:[5]}];g.links.set(5,{origin_id:2,origin_slot:0,target_id:3,target_slot:0});
collector.connect=function(output,target,input){const id=10+target.id;g.links.set(id,{origin_id:this.id,origin_slot:output,target_id:target.id,target_slot:input});this.outputs[output].links=[...(this.outputs[output].links??[]),id];target.inputs[input].link=id};
assert.deepEqual(downstreamLoraConsumers(transformer),[consumer]);
assert.equal(connectLoraConsumerTree(transformer,collector),true);
assert.equal(linkedLocalLoraCollector(transformer),collector);assert.equal(linkedLocalLoraCollector(consumer),collector);
});
test("a consumer added later discovers only its linked upstream LoRA transformer",()=>{
const g=graph(),transformer=node(2,"BV Regional LoRA",g),consumer=node(3,"BV Regional Native Conditioning",g);g._nodes.push(transformer,consumer);
transformer.outputs=[{type:"BV_REGIONAL",links:[5]}];consumer.inputs=[{name:"regional",type:"BV_REGIONAL",link:5}];g.links.set(5,{origin_id:2,origin_slot:0,target_id:3,target_slot:0});
assert.equal(upstreamLoraTransformer(consumer),transformer);
});
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import {readFileSync} from "node:fs";
import React from "../ui/node_modules/react/index.js";
import {renderToStaticMarkup} from "../ui/node_modules/react-dom/server.node.js";
import {LoraV3ResourcePickerPanel} from "../ui/src/regional/LoraV3ResourcePickerPanel.tsx";
import {emptyLoraV3Config,parseLoraV3Config,serializeLoraV3Config} from "../ui/src/regional/loraV3Config.ts";
const collectors=[{id:"collector",label:"Styles",resources:[{id:"skin",label:"Skin"}]}];
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,onCollector(){},onResource(){},onAddExternal(){}}));
assert.match(html,/Styles \/ Skin/);assert.match(html,/External LoRA Stack 1/);
});
test("an unresolved production selection remains explicit",()=>{
const config={version:1,collector_id:"missing",entries:[{id:"entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]}]};
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:false,onCollector(){},onResource(){},onAddExternal(){}}));
assert.match(html,/Unresolved resource selection/);
});
test("config serialization is named and deterministic outside positional visual widgets",()=>{
const value=emptyLoraV3Config();assert.deepEqual(parseLoraV3Config(serializeLoraV3Config(value)),value);
});
test("external entries cannot be created without a live collector resource",()=>{
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors:[],config:emptyLoraV3Config(),resolved:false,onCollector(){},onResource(){},onAddExternal(){}}));
assert.match(html,/disabled=""/);
});
test("production LoRA presentation keeps ComfyUI links authoritative",()=>{
const source=readFileSync(new URL("../ui/src/regional/loraV3Ui.tsx",import.meta.url),"utf8");
assert.match(source,/serialize:false/);assert.match(source,/connectLoraConsumerTree/);assert.match(source,/__bvLoraCollectorIdRemap/);assert.match(source,/__bvLoraResourceIdRemap/);
assert.doesNotMatch(source,/graphToPrompt|queuePrompt|MutationObserver/);
});
+116
View File
@@ -0,0 +1,116 @@
import unittest
from py.util.regional.context import normalize_context
from py.util.regional.lora_v3 import (
LORA_CAPABILITY,
LORA_RESOURCE_TYPE,
build_lora_provider,
register_lora_contracts,
resolve_lora_capability,
transform_lora_capability,
)
DOCUMENT = {
"schema": "bv.regional",
"version": 2,
"document_id": "11111111-1111-4111-8111-111111111111",
"title": "LoRA v3",
"canvas": {"width": 64, "height": 64},
"prompts": {
"global": {"positive_source": "", "negative_source": ""},
"background": {"positive_source": "", "negative_source": ""},
},
"negative_mode": "auto",
"overlap": {"mode": "joint"},
"regions": [],
}
class RegionalLoraV3Tests(unittest.TestCase):
def setUp(self):
self.capabilities, self.resources = register_lora_contracts()
self.context = normalize_context(DOCUMENT, registry=self.capabilities)
def test_external_stack_is_resolved_live_without_entering_context(self):
provider = build_lora_provider(
"22222222-2222-4222-8222-222222222222",
{"skin": {"id": "skin", "name": "Skin", "stack": [["skin.safetensors", 0.8, 0.4]]}},
)
payload = {
"version": 1,
"collector_id": provider["provider_id"],
"entries": [{
"id": "33333333-3333-4333-8333-333333333333",
"source": {"kind": "external", "resource_id": "skin"},
"targets": [{"scope": "global"}],
}],
}
transformed = transform_lora_capability(self.context, payload, registry=self.capabilities)
self.assertNotIn("stack", transformed.require_capability(LORA_CAPABILITY)["entries"][0]["source"])
resolved = resolve_lora_capability(transformed, provider, registry=self.capabilities)
self.assertEqual(resolved["global"], [["skin.safetensors", 0.8, 0.4]])
changed_provider = build_lora_provider(
provider["provider_id"],
{"skin": {"id": "skin", "name": "Skin", "stack": [["skin.safetensors", 0.25, 0.1]]}},
)
self.assertEqual(
resolve_lora_capability(transformed, changed_provider, registry=self.capabilities)["global"],
[["skin.safetensors", 0.25, 0.1]],
)
def test_native_and_external_entries_preserve_semantic_order(self):
provider = build_lora_provider(
"22222222-2222-4222-8222-222222222222",
{"skin": {"id": "skin", "name": "Skin", "stack": [["external.safetensors", 0.5, 0.25]]}},
)
payload = {
"version": 1,
"collector_id": provider["provider_id"],
"entries": [
{"id": "33333333-3333-4333-8333-333333333333", "source": {"kind": "native", "lora_name": "native.safetensors", "model_strength": 1.0, "clip_strength": 0.5}, "targets": [{"scope": "global"}]},
{"id": "44444444-4444-4444-8444-444444444444", "source": {"kind": "external", "resource_id": "skin"}, "targets": [{"scope": "global"}]},
],
}
transformed = transform_lora_capability(self.context, payload, registry=self.capabilities)
self.assertEqual(resolve_lora_capability(transformed, provider, registry=self.capabilities)["global"], [
["native.safetensors", 1.0, 0.5], ["external.safetensors", 0.5, 0.25]
])
def test_provider_identity_is_fail_closed(self):
provider = build_lora_provider("22222222-2222-4222-8222-222222222222", {})
payload = {"version": 1, "collector_id": "55555555-5555-4555-8555-555555555555", "entries": []}
transformed = transform_lora_capability(self.context, payload, registry=self.capabilities)
with self.assertRaisesRegex(ValueError, "mismatched"):
resolve_lora_capability(transformed, provider, registry=self.capabilities)
def test_transform_does_not_mutate_a_branch(self):
payload = {"version": 1, "collector_id": None, "entries": []}
transformed = transform_lora_capability(self.context, payload, registry=self.capabilities)
self.assertNotIn(LORA_CAPABILITY, self.context.capabilities)
self.assertIn(LORA_CAPABILITY, transformed.capabilities)
def test_merge_and_subtract_require_the_same_collector(self):
first = {"version": 1, "collector_id": "22222222-2222-4222-8222-222222222222", "entries": []}
context = transform_lora_capability(self.context, first, registry=self.capabilities)
other = {"version": 1, "collector_id": "55555555-5555-4555-8555-555555555555", "entries": []}
with self.assertRaisesRegex(ValueError, "same collector_id"):
transform_lora_capability(context, other, registry=self.capabilities, operation="merge")
with self.assertRaisesRegex(ValueError, "same collector_id"):
transform_lora_capability(context, other, registry=self.capabilities, operation="subtract")
def test_clear_removes_only_the_lora_capability(self):
payload = {"version": 1, "collector_id": None, "entries": []}
context = transform_lora_capability(self.context, payload, registry=self.capabilities)
cleared = transform_lora_capability(context, {}, registry=self.capabilities, operation="clear")
self.assertNotIn(LORA_CAPABILITY, cleared.capabilities)
self.assertEqual(cleared.core, self.context.core)
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"}
self.assertEqual(self.resources.validate(LORA_RESOURCE_TYPE, reference), reference)
if __name__ == "__main__":
unittest.main()
+22 -2
View File
@@ -62,6 +62,8 @@ class RegionalNodeTests(unittest.TestCase):
expected_categories = {
"BV Regional Prompt": "🌀 BV Node Pack/regional/core",
"BV Named LoRA Stack": "🌀 BV Node Pack/regional/core",
"BV LoRA Stack Collector": "🌀 BV Node Pack/regional/core",
"BV Regional LoRA": "🌀 BV Node Pack/regional/core",
"BV Regional Debug": "🌀 BV Node Pack/regional/core",
"BV Regional Select": "🌀 BV Node Pack/regional/core",
"BV Regional Deconstructor": "🌀 BV Node Pack/regional/core",
@@ -116,7 +118,7 @@ class RegionalNodeTests(unittest.TestCase):
def test_old_prompt_call_and_native_compiler_inputs_remain_compatible(self):
inputs = self.module.BVRegionalNativeConditioningNode.INPUT_TYPES()
self.assertEqual(set(inputs["optional"]), {"lora_registry", "lora_bindings"})
self.assertEqual(set(inputs["optional"]), {"lora_registry", "lora_bindings", "resource_provider"})
self.assertEqual(self.module.BVRegionalPromptNode.RETURN_TYPES[0], "BV_REGIONAL")
def test_named_lora_stack_node_builds_a_chainable_registry(self):
@@ -154,6 +156,24 @@ class RegionalNodeTests(unittest.TestCase):
self.assertIn("green eyes", extracted[1])
self.assertEqual(tuple(rendered[0].shape), (1, 100, 100))
def test_lora_collector_keeps_live_stack_resources_outside_the_context(self):
registry = self.module.BVNamedLoraStackNode().register(
[("portrait.safetensors", 0.8, 0.6)], "Portrait", "stack-a"
)[0]
provider = self.module.BVLoraStackCollectorNode().collect(
registry, "22222222-2222-4222-8222-222222222222"
)[0]
self.assertEqual(provider["resource_type"], "bv-nodepack.lora-stack")
self.assertEqual(provider["resources"]["stack-a"]["stack"][0][0], "portrait.safetensors")
def test_regional_lora_node_returns_a_new_v3_context(self):
source = fixture()
payload = '{"version":1,"collector_id":null,"entries":[]}'
transformed = self.module.BVRegionalLoraNode().transform(source, "replace", payload)[0]
self.assertEqual(transformed["version"], 3)
self.assertIn("bv-nodepack.lora", transformed["capabilities"])
self.assertEqual(source["version"], 1)
def test_detailer_mask_renders_selected_region_at_image_size(self):
document = fixture()
document["version"] = 2
@@ -265,7 +285,7 @@ class RegionalNodeTests(unittest.TestCase):
)
self.assertEqual(
set(self.module.BVRegionalKrea2AttentionNode.INPUT_TYPES()["optional"]),
{"lora_registry", "lora_bindings"},
{"lora_registry", "lora_bindings", "resource_provider"},
)
mode = self.module.BVRegionalKrea2AttentionNode.INPUT_TYPES()["required"]["regional_lora_mode"]
self.assertEqual(mode[0], ["multipass_legacy", "token_gated_singlepass"])
+2
View File
@@ -585,4 +585,6 @@
.bv-m0-picker-host{position:relative;z-index:1;padding:6px}.bv-m0-picker-panel{display:grid;gap:7px}.bv-m0-picker-panel>.bv-control-field{grid-template-columns:1fr auto;align-items:center}.bv-m0-picker-panel>.bv-control-field>.bv-toggle{justify-self:end}.bv-resource-picker{display:grid;gap:5px}.bv-resource-picker-summary{padding:5px 7px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-resource-picker.is-unresolved .bv-resource-picker-summary{color:var(--bv-ui-danger);border-color:var(--bv-ui-danger)}.bv-resource-picker label{display:grid;grid-template-columns:72px 1fr;align-items:center;gap:6px}.bv-resource-picker select{min-width:0}
.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:8px}.bv-lora-v3-picker-panel>section{display:grid;gap:5px;padding:6px;border:1px solid var(--bv-ui-border);border-radius:4px}
.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}
+7 -1
View File
@@ -24,6 +24,8 @@ import { visibleExternalDetectorSlots } from "./regional/detectorExternalInputs"
import { DETAILER_UI_NODES, detailerUiLabel } from "./regional/detailerLoopUi";
import { upgradeRemoteLLMProvider } from "./remoteLLM";
import { installM0ResourceSpike } from "./regional/m0ResourceSpike";
import { installLoraV3Ui } from "./regional/loraV3Ui";
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";
import { BvGlobalToastStack, BV_TOOLBAR_LAUNCHER_TOGGLE_EVENT, collectScopedNodes, lastBvFullWindowType, lastBvWindowInstance, rememberBvWindowInstance, setWindowMenuVisible, showBvToast, switchBvView, ToolbarWindowLauncher, ToolbarLauncherColumn, windowMenuVisible } from "./ui";
const comfyApp = getApp();
@@ -574,6 +576,7 @@ comfyApp.registerExtension({
name: "bv_nodepack.control_rack_portal",
setup() {
ensureMountedOnce();
installM0CanvasVisibility((comfyApp as any).canvas);
applyUiPreferences((comfyApp as any).ui?.settings);
installGlobalTextareaCompletion();
debugBridgeEnabled = Boolean((comfyApp as any).ui?.settings?.getSettingValue?.(DEBUG_BRIDGE_SETTING_ID, false));
@@ -692,6 +695,7 @@ comfyApp.registerExtension({
}],
beforeRegisterNodeDef(nodeType: any, nodeData: any) {
if (installM0ResourceSpike(nodeType, nodeData)) return;
if (installLoraV3Ui(nodeType, nodeData)) return;
if(["BV Control Center","BV Regional Prompt","BV Regional Detailer Plan","BV Detector Registry","BV Smart Pipe","BV Smart Pipe Merge"].includes(nodeData.name)){
const created=nodeType.prototype.onNodeCreated,removed=nodeType.prototype.onRemoved;
nodeType.prototype.onNodeCreated=function(){const result=created?.apply(this,arguments);if(nodeData.name!=="BV Control Center"){this.properties??={};if(typeof this.properties.bvWindowMenuVisible!=="boolean")this.properties.bvWindowMenuVisible=!['BV Smart Pipe','BV Smart Pipe Merge'].includes(nodeData.name)}queueMicrotask(refreshBvToolbarCapabilities);return result};
@@ -785,7 +789,9 @@ comfyApp.registerExtension({
if (!idWidget) return;
const currentId = String(idWidget.value ?? "").trim();
if (!currentId || needsFreshStackId(String(node.id), currentId, namedLoraStacks())) {
idWidget.value = crypto.randomUUID();
const nextId = crypto.randomUUID();
if(currentId)node.__bvLoraResourceIdRemap={[currentId]:nextId};
idWidget.value = nextId;
node.setDirtyCanvas?.(true, true);
}
hideRegionalWidget(idWidget);
@@ -0,0 +1,14 @@
import React from "react";
import { Button, ResourcePicker, ResourcePickerCollector } 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 function LoraV3ResourcePickerPanel({collectors,config,resolved,onCollector,onResource,onAddExternal}:{collectors:ResourcePickerCollector[];config:LoraV3Config;resolved:boolean;onCollector:(id:string)=>void;onResource:(entryId:string,resourceId:string)=>void;onAddExternal:()=>void}){
const external=config.entries.filter((entry):entry is LoraV3Entry&{source:{kind:"external";resource_id:string}}=>entry.source.kind==="external");
return <div className="bv-lora-v3-picker-panel">
{external.map((entry,index)=><section key={entry.id}><strong>External 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)}/></section>) }
{!external.length&&<p className="bv-field-help">No external live stack is configured.</p>}
<Button intent="secondary" disabled={!collectors.some(collector=>collector.resources.length>0)} onClick={onAddExternal}>Add external stack</Button>
</div>;
}
+11
View File
@@ -0,0 +1,11 @@
import type { LoraV3Config } from "./LoraV3ResourcePickerPanel";
export const emptyLoraV3Config=():LoraV3Config=>({version:1,collector_id:null,entries:[]});
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");
return structuredClone(candidate as LoraV3Config);
}
export const serializeLoraV3Config=(value:LoraV3Config)=>JSON.stringify(value);
+88
View File
@@ -0,0 +1,88 @@
export const LORA_PROVIDER_TYPE="BV_RUNTIME_RESOURCE_PROVIDER";
export const LORA_PROVIDER_SLOT="resource_provider";
export const LORA_COLLECTOR_NODE="BV LoRA Stack Collector";
const graphLink=(graph:any,id:any)=>id==null?null:(graph?._links?.get?.(id)??graph?.links?.get?.(id)??graph?.links?.[id]??null);
const slotIndex=(slots:any[]|undefined)=>slots?.findIndex(slot=>slot?.name===LORA_PROVIDER_SLOT||slot?.type===LORA_PROVIDER_TYPE)??-1;
export function ensureLoraCollectorOutput(node:any){
let index=slotIndex(node.outputs);
if(index<0&&node.addOutput){node.addOutput(LORA_PROVIDER_SLOT,LORA_PROVIDER_TYPE);index=slotIndex(node.outputs);}
const output=node.outputs?.[index];
if(output){output.name=LORA_PROVIDER_SLOT;output.type=LORA_PROVIDER_TYPE;output.hidden=true;output.__bvResourceSlot=true;}
return index;
}
export function ensureLoraConsumerInput(node:any){
let index=slotIndex(node.inputs);
if(index<0&&node.addInput){node.addInput(LORA_PROVIDER_SLOT,LORA_PROVIDER_TYPE);index=slotIndex(node.inputs);}
const input=node.inputs?.[index];
if(input){input.name=LORA_PROVIDER_SLOT;input.type=LORA_PROVIDER_TYPE;input.hidden=true;input.__bvResourceSlot=true;}
return index;
}
export function linkedLocalLoraCollector(node:any){
const input=node.inputs?.find((item:any)=>item.name===LORA_PROVIDER_SLOT||item.type===LORA_PROVIDER_TYPE);
const link=graphLink(node.graph,input?.link);
if(!link||String(link.target_id)!==String(node.id))return null;
const source=node.graph?.getNodeById?.(link.origin_id);
const output=source?.outputs?.[Number(link.origin_slot)];
return source?.graph===node.graph&&(source?.type===LORA_COLLECTOR_NODE||output?.type===LORA_PROVIDER_TYPE)?source:null;
}
export function connectLocalLoraCollector(consumer:any,collector:any|null){
const input=ensureLoraConsumerInput(consumer);
if(input<0)return false;
if(consumer.inputs?.[input]?.link!=null)consumer.disconnectInput?.(input);
if(!collector)return true;
if(collector.graph!==consumer.graph)return false;
const output=ensureLoraCollectorOutput(collector);
if(output<0)return false;
collector.connect?.(output,consumer,input);
return linkedLocalLoraCollector(consumer)===collector;
}
export function localLoraCollectors(node:any){
return (node.graph?._nodes??[]).filter((candidate:any)=>candidate!==node&&candidate.graph===node.graph&&candidate.type===LORA_COLLECTOR_NODE);
}
const LORA_EXECUTORS=new Set(["BV Regional Native Conditioning","BV Regional Krea 2 Attention","BV Regional Anima Conditioning"]);
export function downstreamLoraConsumers(transformer:any){
const graph=transformer.graph,queue=[transformer],seen=new Set<any>([transformer]),found:any[]=[];
while(queue.length){
const source=queue.shift();
for(const output of source?.outputs??[]){
if(output?.type!=="BV_REGIONAL")continue;
for(const linkId of output.links??[]){
const link=graphLink(graph,linkId),target=link&&graph?.getNodeById?.(link.target_id);
if(!target||target.graph!==graph||seen.has(target))continue;
seen.add(target);
if(LORA_EXECUTORS.has(target.type))found.push(target);
if((target.outputs??[]).some((item:any)=>item.type==="BV_REGIONAL")){queue.push(target);}
}
}
}
return found;
}
export function connectLoraConsumerTree(transformer:any,collector:any|null){
const targets=[transformer,...downstreamLoraConsumers(transformer)];
return targets.map(target=>connectLocalLoraCollector(target,collector)).every(Boolean);
}
export function upstreamLoraTransformer(consumer:any){
const graph=consumer.graph,queue=[consumer],seen=new Set<any>([consumer]);
while(queue.length){
const target=queue.shift();
for(const input of target?.inputs??[]){
if(input?.type!=="BV_REGIONAL")continue;
const link=graphLink(graph,input.link),source=link&&graph?.getNodeById?.(link.origin_id);
if(!source||source.graph!==graph||seen.has(source))continue;
if(source.type==="BV Regional LoRA")return source;
seen.add(source);queue.push(source);
}
}
return null;
}
+81
View File
@@ -0,0 +1,81 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { getApp } from "../appHelper.js";
import { LoraV3ResourcePickerPanel, LoraV3Config } from "./LoraV3ResourcePickerPanel";
import { parseLoraV3Config, serializeLoraV3Config } from "./loraV3Config";
import { connectLocalLoraCollector, connectLoraConsumerTree, ensureLoraCollectorOutput, ensureLoraConsumerInput, linkedLocalLoraCollector, localLoraCollectors, upstreamLoraTransformer } from "./loraV3Graph";
import { installM0CanvasVisibility } from "./m0VisualProjection";
const widget=(node:any,name:string)=>node.widgets?.find((item:any)=>item.name===name);
const graphLink=(graph:any,id:any)=>id==null?null:(graph?._links?.get?.(id)??graph?.links?.get?.(id)??graph?.links?.[id]??null);
const hide=(item:any)=>{if(!item)return;item.type="hidden";item.computeSize=()=>[0,-4];item.serializeValue=item.serializeValue??function(this:any){return this.value};};
const ensureId=(item:any)=>{if(item&&!String(item.value??"").trim())item.value=crypto.randomUUID();};
const ensureCanvasVisibility=()=>{let attempts=0;const apply=()=>{const canvas=getApp().canvas;if(canvas)installM0CanvasVisibility(canvas);else if(attempts++<40)setTimeout(apply,50)};apply();};
function upstreamNamedStacks(collector:any){
const found:any[]=[],seen=new Set<any>(),queue=[collector];
while(queue.length){
const target=queue.shift();if(!target||seen.has(target))continue;seen.add(target);
for(const input of target.inputs??[]){
if(!["lora_registry","registry"].includes(input.name))continue;
const link=graphLink(target.graph,input.link),source=link&&target.graph?.getNodeById?.(link.origin_id);
if(!source||source.graph!==target.graph)continue;
if(source.type==="BV Named LoRA Stack"){
const id=String(widget(source,"stack_id")?.value??"").trim(),label=String(widget(source,"name")?.value??"").trim();
if(id&&label)found.push({id,label,node:source});
}
queue.push(source);
}
}
return found.reverse();
}
function catalog(node:any){
return localLoraCollectors(node).map((collector:any)=>({
id:String(widget(collector,"collector_id")?.value??"").trim(),
label:String(collector.title||"BV LoRA Stack Collector"),
resources:upstreamNamedStacks(collector),node:collector,
})).filter((item:any)=>item.id);
}
function installCollector(node:any){
ensureCanvasVisibility();
const id=widget(node,"collector_id");ensureId(id);const current=String(id?.value??"").trim(),duplicate=(node.graph?._nodes??[]).find((item:any)=>item!==node&&item.type==="BV LoRA Stack Collector"&&String(widget(item,"collector_id")?.value??"").trim()===current);
if(id&&current&&duplicate){const next=crypto.randomUUID();id.value=next;node.__bvLoraCollectorIdRemap={[current]:next};}
hide(id);ensureLoraCollectorOutput(node);node.__bvRuntimeResourceProvider=true;
}
function installConsumerSlot(node:any){
ensureCanvasVisibility();
const index=ensureLoraConsumerInput(node),input=node.inputs?.[index];if(input){input.hidden=true;input.__bvM0VisualHidden=true;input.__bvM0PortHidden=true;input.__bvM0ResourceSlot=true;}
if(!linkedLocalLoraCollector(node)){const transformer=upstreamLoraTransformer(node),collector=transformer&&linkedLocalLoraCollector(transformer);if(collector)connectLocalLoraCollector(node,collector);}
}
function renderTransformer(node:any){
installConsumerSlot(node);
const configWidget=widget(node,"config_json");if(!configWidget)return;
let config:LoraV3Config;try{config=parseLoraV3Config(configWidget.value)}catch{return;}
const choices=catalog(node),linked=linkedLocalLoraCollector(node),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)};configWidget.value=serializeLoraV3Config(config);}
const resolved=Boolean(linked)&&String(widget(linked,"collector_id")?.value??"")===String(config.collector_id??"");
if(config.collector_id&&!resolved){config={...config,collector_id:null,entries:config.entries.map(entry=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:""}}:entry)};configWidget.value=serializeLoraV3Config(config);connectLoraConsumerTree(node,null);}
let host=node.__bvLoraPickerHost as HTMLDivElement|undefined;
if(!host){host=document.createElement("div");host.className="bv-ui bv-lora-v3-picker-host";node.__bvLoraPickerHost=host;const dom=node.addDOMWidget?.("bv_lora_resource_picker","div",host,{serialize:false,getMinHeight:()=>Math.max(140,90+config.entries.length*90)});if(dom)dom.serialize=false;node.__bvLoraPickerRoot=ReactDOM.createRoot(host);}
const save=(next:LoraV3Config)=>{configWidget.value=serializeLoraV3Config(next);node.setDirtyCanvas?.(true,true);queueMicrotask(()=>renderTransformer(node));};
const selectable=choices.filter((choice:any)=>choice.resources.length>0);
node.__bvLoraPickerRoot.render(<LoraV3ResourcePickerPanel collectors={selectable} config={config} resolved={resolved}
onCollector={id=>{const choice=choices.find((item:any)=>item.id===id);const first=choice?.resources[0]?.id??"";const next={...config,collector_id:id||null,entries:config.entries.map(entry=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:first}}:entry)};connectLoraConsumerTree(node,choice?.node??null);save(next);}}
onResource={(entryId,id)=>save({...config,entries:config.entries.map(entry=>entry.id===entryId&&entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:id}}:entry)})}
onAddExternal={()=>{const choice=selectable.find((item:any)=>item.id===config.collector_id)??selectable[0],resource=choice?.resources[0];if(!choice||!resource)return;connectLoraConsumerTree(node,choice.node);save({...config,collector_id:choice.id,entries:[...config.entries,{id:crypto.randomUUID(),source:{kind:"external",resource_id:resource.id},targets:[{scope:"global"}]}]});}}/>);
}
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;
const upgrade=function(this:any){if(collector)installCollector(this);if(transformer)renderTransformer(this);if(consumer)installConsumerSlot(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;
}
+6 -3
View File
@@ -1,4 +1,6 @@
const M0_PROVIDER_TYPE="BV_RUNTIME_RESOURCE_PROVIDER_M0";
const V3_PROVIDER_TYPE="BV_RUNTIME_RESOURCE_PROVIDER";
const providerType=(value:unknown)=>value===M0_PROVIDER_TYPE||value===V3_PROVIDER_TYPE;
type M0Slot = { name?:string; label?:string; localized_name?:string; type?:string; hidden?:boolean; __bvM0VisualHidden?: boolean; __bvM0PortHidden?:boolean; __bvM0ResourceSlot?: boolean; link?: unknown; draw?:(...args:any[])=>unknown; drawCollapsed?:(...args:any[])=>unknown };
type M0Node = { id?: string|number; inputs?: M0Slot[]; outputs?: M0Slot[]; properties?:Record<string,unknown>; __bvM0ResourceConsumer?:boolean; __bvM0FanInAnchorSlot?:number; __bvM0FanInPoint?:Readonly<[number,number]>; __bvM0ElementMarkRevision?:number; getConnectionPos?:(input:boolean,slot:number)=>Readonly<[number,number]> };
@@ -8,9 +10,10 @@ function workflowHasM0Debug(graph:any){
function hiddenLink(canvas:any,link:any) {
if (!link) return false;
if(resourceLink(canvas,link))return !workflowHasM0Debug(canvas.graph);
const origin=canvas.graph?.getNodeById?.(link.origin_id) as M0Node|undefined;
const target=canvas.graph?.getNodeById?.(link.target_id) as M0Node|undefined;
if(origin?.outputs?.[link.origin_slot]?.type===V3_PROVIDER_TYPE||target?.inputs?.[link.target_slot]?.type===V3_PROVIDER_TYPE)return true;
if(resourceLink(canvas,link))return !workflowHasM0Debug(canvas.graph);
return Boolean(origin?.outputs?.[link.origin_slot]?.__bvM0VisualHidden||target?.inputs?.[link.target_slot]?.__bvM0VisualHidden);
}
@@ -19,7 +22,7 @@ function resourceLink(canvas:any,link:any) {
const origin=canvas.graph?.getNodeById?.(link.origin_id) as M0Node|undefined;
const target=canvas.graph?.getNodeById?.(link.target_id) as M0Node|undefined;
const output=origin?.outputs?.[link.origin_slot],input=target?.inputs?.[link.target_slot];
return Boolean((output?.__bvM0ResourceSlot||output?.type===M0_PROVIDER_TYPE)&&(input?.__bvM0ResourceSlot||input?.type===M0_PROVIDER_TYPE));
return Boolean((output?.__bvM0ResourceSlot||providerType(output?.type))&&(input?.__bvM0ResourceSlot||providerType(input?.type)));
}
export function requestM0DebugAnimation(canvas:any){
@@ -68,7 +71,7 @@ export function installM0CanvasVisibility(canvas: any) {
canvas.drawNode = function (node:M0Node,ctx:CanvasRenderingContext2D) {
const restores:Array<()=>void>=[];
for(const slot of [...(node.inputs??[]),...(node.outputs??[])]){
if(slot.type!==M0_PROVIDER_TYPE)continue;
if(!providerType(slot.type))continue;
for(const field of ["name","label","localized_name"] as const){
const own=Object.prototype.hasOwnProperty.call(slot,field),value=slot[field];
Object.defineProperty(slot,field,{configurable:true,writable:true,value:""});