Add Regional v3 LoRA vertical slice
This commit is contained in:
+25
-25
File diff suppressed because one or more lines are too long
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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&¤t&&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;
|
||||
}
|
||||
@@ -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:""});
|
||||
|
||||
Reference in New Issue
Block a user