Unify Regional LoRA scope editing

This commit is contained in:
BlackVortexAI
2026-08-23 17:30:20 +02:00
parent 78d3b50424
commit 84831dcf3a
9 changed files with 80 additions and 74 deletions
+25 -25
View File
File diff suppressed because one or more lines are too long
+21 -11
View File
@@ -3,35 +3,45 @@ 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,OptionalLoraV3ScopePicker} from "../ui/src/regional/LoraV3ResourcePickerPanel.tsx";
import {LoraV3ResourcePickerPanel,OptionalLoraV3ScopePicker,withoutLoraV3Target} 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/);
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:true,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}}],onCollector(){},onResource(){},onAddExternal(){}}));
assert.match(html,/Global/);assert.match(html,/LoRA Stack 1/);assert.doesNotMatch(html,/Styles \/ Skin/);
});
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/);
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:false,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}}],onCollector(){},onResource(){},onAddExternal(){},onRemove(){}}));
assert.match(html,/is-unresolved/);assert.doesNotMatch(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(){}}));
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors:[],config:emptyLoraV3Config(),resolved:false,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}}],onCollector(){},onResource(){},onAddExternal(){},onRemove(){}}));
assert.match(html,/disabled=""/);
});
test("the advanced LoRA editor exposes assignment targets for every stack",()=>{
test("the advanced LoRA editor groups multiple stacks below every assignment target",()=>{
const config={version:1,collector_id:"collector",entries:[{id:"entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"},{scope:"region",document_id:"doc",region_id:"left"}]}]};
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:true,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}},{value:"region:doc:left",label:"Left",target:{scope:"region",document_id:"doc",region_id:"left"}}],onCollector(){},onResource(){},onTargets(){},onAddExternal(){}}));
assert.match(html,/Assignment targets/);assert.match(html,/Global/);assert.match(html,/Left/);
const html=renderToStaticMarkup(React.createElement(LoraV3ResourcePickerPanel,{collectors,config,resolved:true,targetOptions:[{value:"global",label:"Global",target:{scope:"global"}},{value:"region:doc:left",label:"Left",target:{scope:"region",document_id:"doc",region_id:"left"}}],onCollector(){},onResource(){},onAddExternal(){}}));
assert.match(html,/Global/);assert.match(html,/Left/);assert.match(html,/Add LoRA stack/);assert.doesNotMatch(html,/Assignment targets/);
});
test("Regional Prompt easy mode starts with a BV toggle and hides an unassigned picker",()=>{
const html=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{label:"Global LoRA Stack",collectors,config:emptyLoraV3Config(),target:{scope:"global"},resolved:false,onValue(){}}));
assert.match(html,/role="switch"/);assert.match(html,/Disabled/);assert.doesNotMatch(html,/No resource selected|Collector/);
const html=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{collectors,config:emptyLoraV3Config(),target:{scope:"global"},resolved:false,onCollector(){},onResource(){},onAdd(){},onRemove(){},onClear(){}}));
assert.match(html,/role="switch"/);assert.match(html,/LoRA disabled/);assert.doesNotMatch(html,/No resource selected|Collector/);
});
test("Regional Prompt easy mode renders every stack assigned to its scope",()=>{
const config={version:1,collector_id:"collector",entries:[{id:"first",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]},{id:"second",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"}]}]};
const html=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{collectors,config,target:{scope:"global"},resolved:true,onCollector(){},onResource(){},onAdd(){},onRemove(){},onClear(){}}));
assert.match(html,/LoRA enabled/);assert.match(html,/LoRA Stack 1/);assert.match(html,/LoRA Stack 2/);assert.match(html,/Add LoRA stack/);
});
test("removing one scope preserves shared assignments and the input config",()=>{
const original={version:1,collector_id:"collector",entries:[{id:"shared",source:{kind:"external",resource_id:"skin"},targets:[{scope:"global"},{scope:"region",document_id:"doc",region_id:"left"}]}]};
const next=withoutLoraV3Target(original,{scope:"global"},"shared");
assert.deepEqual(next.entries[0].targets,[{scope:"region",document_id:"doc",region_id:"left"}]);assert.equal(original.entries[0].targets.length,2);
});
test("production LoRA presentation keeps ComfyUI links authoritative",()=>{
const source=readFileSync(new URL("../ui/src/regional/loraV3Ui.tsx",import.meta.url),"utf8");
@@ -14,13 +14,13 @@ const collectors=[{id:"collector-1",label:"Collector One",resources:[{id:"resour
test("a catalog match without a real link stays visibly unresolved",()=>{
const html=renderToStaticMarkup(React.createElement(ResourcePicker,{collectors,collectorId:"collector-1",resourceId:"resource-1",resolved:false,onCollector(){},onResource(){}}));
assert.match(html,/is-unresolved/);
assert.match(html,/Unresolved resource selection/);
assert.doesNotMatch(html,/Unresolved resource selection/);
assert.doesNotMatch(html,/Collector One \/ Alpha/);
});
test("a linked id selection renders the closed Collector / Resource summary",()=>{
test("a linked id selection renders both closed BV dropdown values without a duplicate summary",()=>{
const html=renderToStaticMarkup(React.createElement(ResourcePicker,{collectors,collectorId:"collector-1",resourceId:"resource-1",resolved:true,onCollector(){},onResource(){}}));
assert.match(html,/Collector One \/ Alpha/);
assert.match(html,/>Collector One</);assert.match(html,/>Alpha</);assert.doesNotMatch(html,/Collector One \/ Alpha/);
});
test("Nodes 2.0 graph slots are repaired idempotently before linking",()=>{
@@ -77,8 +77,7 @@ test("a direct same-graph link resolves its local collector",()=>{
test("the shared resource picker uses BV popover selects and treats empty as neutral",()=>{
const html=renderToStaticMarkup(React.createElement(ResourcePicker,{collectors,collectorId:"",resourceId:"",resolved:false,onCollector(){},onResource(){}}));
assert.match(html,/No resource selected/);
assert.doesNotMatch(html,/Unresolved resource selection|<select\b/);
assert.doesNotMatch(html,/No resource selected|Unresolved resource selection|bv-resource-picker-summary|<select\b/);
assert.match(html,/aria-haspopup="listbox"/);
});
@@ -103,7 +102,7 @@ test("the shared picker panel exposes a non-serialized debug control",()=>{
const html=renderToStaticMarkup(React.createElement(M0ResourcePickerPanel,{collectors,collectorId:"collector-1",resourceId:"resource-1",resolved:true,debugVisible:false,onCollector(){},onResource(){},onDebug(){}}));
assert.match(html,/Hidden link debug/);
assert.match(html,/role="switch"/);
assert.match(html,/Collector One \/ Alpha/);
assert.match(html,/>Collector One</);assert.match(html,/>Alpha</);
});
test("Nodes 2.0 hides every persisted id widget without leaving click overlays",()=>{
+2 -2
View File
@@ -582,9 +582,9 @@
.bv-showcase>aside a:hover { background:rgba(var(--bv-ui-accent-rgb),.12); }
.bv-image-preview-action:hover:not(:disabled),.bv-image-picker-toggle:hover:not(:disabled) { background:rgba(var(--bv-ui-accent-rgb),.13); }
.bv-image-preview-icon { color:var(--bv-ui-accent-soft); background:rgba(var(--bv-ui-accent-rgb),.13); border-color:rgba(var(--bv-ui-accent-rgb),.25); }
.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 .bv-field-grid{align-items:end}.bv-resource-picker .bv-select-trigger{width:100%}
.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.is-unresolved .bv-select-trigger{border-color:var(--bv-ui-danger)}.bv-resource-picker .bv-field-grid{align-items:end}.bv-resource-picker .bv-select-trigger{width:100%}
.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:7px;padding:8px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-optional-lora-scope{display:grid;gap:7px}.bv-lora-v3-scope-picker{display:grid;gap:5px}.bv-lora-v3-window{display:grid;gap:10px;padding:10px;overflow:auto}
.bv-lora-v3-picker-host{position:relative;z-index:1;padding:6px}.bv-lora-v3-picker-panel{display:grid;gap:10px}.bv-lora-v3-target{display:grid;gap:8px;padding:9px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-target>h3{margin:0}.bv-optional-lora-scope{display:grid;gap:7px}.bv-lora-v3-enable-row{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:12px}.bv-lora-v3-enable-row>.bv-control-field>.bv-control-label{display:none}.bv-lora-v3-enable-row>.bv-control-field{padding-top:20px}.bv-lora-v3-scope-picker{display:grid;gap:8px}.bv-lora-v3-stack-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:end;padding:7px;border:1px solid var(--bv-ui-border);border-radius:4px}.bv-lora-v3-stack-row>strong,.bv-lora-v3-stack-row>.bv-resource-picker{grid-column:1}.bv-lora-v3-stack-row>.bv-button{grid-column:2;grid-row:2}.bv-lora-v3-window{display:grid;gap:10px;padding:10px;overflow:auto}
.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}
+2 -4
View File
@@ -2,8 +2,7 @@ import React, { useEffect, useState } from "react";
import { BvSelect } from "../ui/controls";
import { BvManagedWindow } from "../ui/window";
import { LoraV3ResourcePickerPanel, type LoraV3Config } from "./LoraV3ResourcePickerPanel";
import { connectLoraConsumerTree } from "./loraV3Graph";
import { loraV3Catalog, loraV3Resolved, loraV3TargetOptions, readNodeLoraV3Config, writeNodeLoraV3Config } from "./loraV3Ui";
import { addLoraV3TargetEntry, loraV3Catalog, loraV3Resolved, loraV3TargetOptions, readNodeLoraV3Config, removeLoraV3TargetEntry, setLoraV3Collector, setLoraV3EntryResource } from "./loraV3Ui";
const widget=(node:any,name:string)=>node?.widgets?.find((item:any)=>item.name===name);
@@ -12,11 +11,10 @@ export default function LoraV3EditorWindow({open,node,onClose}:{open:boolean;nod
useEffect(()=>{if(open&&node)setConfig(readNodeLoraV3Config(node));},[node,open]);
if(!node)return null;
const collectors=loraV3Catalog(node).filter(item=>item.resources.length>0),resolved=loraV3Resolved(node,config),targetOptions=loraV3TargetOptions(node);
const save=(next:LoraV3Config)=>{writeNodeLoraV3Config(node,next);setConfig(next);};
return <BvManagedWindow open={open} title="BV Regional LoRA Editor" initialGeometry={{width:620,height:520}} minSize={{width:420,height:320}} onClose={onClose}>
<div className="bv-ui bv-lora-v3-window">
<BvSelect label="Operation" value={String(widget(node,"operation")?.value??"replace")} onChange={value=>{const item=widget(node,"operation");if(item){item.value=value;item.callback?.(value);node.setDirtyCanvas?.(true,true);}}}><option value="replace">Replace</option><option value="merge">Merge</option><option value="subtract">Subtract</option><option value="clear">Clear</option></BvSelect>
<LoraV3ResourcePickerPanel collectors={collectors} config={config} resolved={resolved} targetOptions={targetOptions} onCollector={id=>{const choice=collectors.find(item=>item.id===id),first=choice?.resources[0]?.id??"";connectLoraConsumerTree(node,choice?.node??null);save({...config,collector_id:id||null,entries:config.entries.map(entry=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:first}}:entry)});}} 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)})} onTargets={(entryId,targets)=>save({...config,entries:config.entries.map(entry=>entry.id===entryId?{...entry,targets}:entry)})} onAddExternal={()=>{const choice=collectors.find(item=>item.id===config.collector_id)??collectors[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"}]}]});}} onRemove={entryId=>{const entries=config.entries.filter(entry=>entry.id!==entryId),next={...config,collector_id:entries.some(entry=>entry.source.kind==="external")?config.collector_id:null,entries};if(!next.collector_id)connectLoraConsumerTree(node,null);save(next);}}/>
<LoraV3ResourcePickerPanel collectors={collectors} config={config} resolved={resolved} targetOptions={targetOptions} onCollector={id=>setConfig(setLoraV3Collector(node,config,id))} onResource={(entryId,id)=>setConfig(setLoraV3EntryResource(node,config,entryId,id))} onAddExternal={target=>setConfig(addLoraV3TargetEntry(node,config,target))} onRemove={(entryId,target)=>setConfig(removeLoraV3TargetEntry(node,config,entryId,target))}/>
</div>
</BvManagedWindow>;
}
+14 -14
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, FieldFrame, ResourcePicker, ResourcePickerCollector, TagSelect, ToggleField, type SelectOption } from "../ui/components";
import { Button, ResourcePicker, ResourcePickerCollector, ToggleField, type SelectOption } from "../ui/components";
export type LoraV3Entry={id:string;source:{kind:"external";resource_id:string}|{kind:"native";lora_name:string;model_strength:number;clip_strength:number};targets:Record<string,unknown>[]};
export type LoraV3Config={version:1;collector_id:string|null;entries:LoraV3Entry[]};
@@ -9,23 +9,23 @@ export type LoraV3TargetOption=SelectOption&{target:LoraV3Target};
export const loraV3TargetValue=(target:LoraV3Target)=>target.scope==="global"?"global":`region:${target.document_id}:${target.region_id}`;
export function sameLoraTarget(left:Record<string,unknown>,right:LoraV3Target){return left.scope===right.scope&&(right.scope==="global"||(left.document_id===right.document_id&&left.region_id===right.region_id));}
export function withoutLoraV3Target(config:LoraV3Config,target:LoraV3Target,entryId?:string){const entries=config.entries.flatMap(entry=>{if(entryId&&entry.id!==entryId)return [entry];const targets=entry.targets.filter(candidate=>!sameLoraTarget(candidate,target));return targets.length?[{...entry,targets}]:[];});return {...config,collector_id:entries.some(entry=>entry.source.kind==="external")?config.collector_id:null,entries};}
export function LoraV3ScopePicker({label,help,collectors,config,target,resolved,onValue}:{label:string;help?:string;collectors:ResourcePickerCollector[];config:LoraV3Config;target:LoraV3Target;resolved:boolean;onValue:(collectorId:string|null,resourceId:string|null)=>void}){
const entry=config.entries.find(item=>item.source.kind==="external"&&item.targets.some(candidate=>sameLoraTarget(candidate,target))),resourceId=entry?.source.kind==="external"?entry.source.resource_id:"";
return <FieldFrame label={label} help={help}><div className="bv-lora-v3-scope-picker"><ResourcePicker collectors={collectors} collectorId={config.collector_id??""} resourceId={resourceId} resolved={!resourceId||resolved} onCollector={collectorId=>{const resource=collectors.find(item=>item.id===collectorId)?.resources[0]?.id??null;onValue(collectorId||null,resource)}} onResource={id=>onValue(config.collector_id,id||null)}/>{resourceId&&<Button density="compact" intent="ghost" onClick={()=>onValue(config.collector_id,null)}>Clear</Button>}</div></FieldFrame>;
type ScopeCallbacks={onCollector:(id:string)=>void;onResource:(entryId:string,resourceId:string)=>void;onAdd:(target:LoraV3Target)=>void;onRemove:(entryId:string,target:LoraV3Target)=>void};
type ScopeProps=ScopeCallbacks&{collectors:ResourcePickerCollector[];config:LoraV3Config;target:LoraV3Target;resolved:boolean};
const externalFor=(config:LoraV3Config,target:LoraV3Target)=>config.entries.filter((entry):entry is LoraV3Entry&{source:{kind:"external";resource_id:string}}=>entry.source.kind==="external"&&entry.targets.some(candidate=>sameLoraTarget(candidate,target)));
export function LoraV3ScopePicker({collectors,config,target,resolved,onCollector,onResource,onAdd,onRemove}:ScopeProps){
const entries=externalFor(config,target);
return <div className="bv-lora-v3-scope-picker">{entries.map((entry,index)=><section className="bv-lora-v3-stack-row" key={entry.id}><strong>LoRA Stack {index+1}</strong><ResourcePicker collectors={collectors} collectorId={config.collector_id??""} resourceId={entry.source.resource_id} resolved={resolved} onCollector={onCollector} onResource={id=>onResource(entry.id,id)}/><Button density="compact" intent="ghost" onClick={()=>onRemove(entry.id,target)}>Remove</Button></section>)}<Button intent="secondary" disabled={!collectors.some(collector=>collector.resources.length>0)} onClick={()=>onAdd(target)}>Add LoRA stack</Button></div>;
}
export function OptionalLoraV3ScopePicker(props:{label:string;help?:string;collectors:ResourcePickerCollector[];config:LoraV3Config;target:LoraV3Target;resolved:boolean;onValue:(collectorId:string|null,resourceId:string|null)=>void}){
const targetKey=loraV3TargetValue(props.target),assigned=props.config.entries.some(entry=>entry.source.kind==="external"&&entry.targets.some(target=>sameLoraTarget(target,props.target))),[enabled,setEnabled]=useState(assigned);
export function OptionalLoraV3ScopePicker(props:ScopeProps&{onClear:(target:LoraV3Target)=>void}){
const targetKey=loraV3TargetValue(props.target),assigned=externalFor(props.config,props.target).length>0,[enabled,setEnabled]=useState(assigned);
useEffect(()=>setEnabled(assigned),[assigned,targetKey]);
return <div className="bv-optional-lora-scope"><ToggleField label="Use LoRA" value={enabled} trueLabel="Enabled" falseLabel="Disabled" help="Show or hide the LoRA assignment for this scope." onValue={next=>{setEnabled(next);if(!next)props.onValue(props.config.collector_id,null);}}/>{enabled&&<LoraV3ScopePicker {...props}/>}</div>;
return <div className="bv-optional-lora-scope"><div className="bv-lora-v3-enable-row"><ToggleField label="" value={enabled} trueLabel="LoRA enabled" falseLabel="LoRA disabled" onValue={next=>{setEnabled(next);if(next&&!assigned)props.onAdd(props.target);if(!next)props.onClear(props.target);}}/>{enabled&&<LoraV3ScopePicker {...props}/>}</div></div>;
}
export function LoraV3ResourcePickerPanel({collectors,config,resolved,targetOptions=[],onCollector,onResource,onTargets,onAddExternal,onRemove}:{collectors:ResourcePickerCollector[];config:LoraV3Config;resolved:boolean;targetOptions?:LoraV3TargetOption[];onCollector:(id:string)=>void;onResource:(entryId:string,resourceId:string)=>void;onTargets?:(entryId:string,targets:LoraV3Target[])=>void;onAddExternal:()=>void;onRemove?:(entryId:string)=>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)}/>{onTargets&&targetOptions.length>0&&<TagSelect label="Assignment targets" value={entry.targets.map(target=>loraV3TargetValue(target as LoraV3Target))} options={targetOptions} minSelected={1} onValue={values=>onTargets(entry.id,values.flatMap(value=>{const target=targetOptions.find(option=>option.value===value)?.target;return target?[target]:[]}))}/>} {onRemove&&<Button density="compact" intent="ghost" onClick={()=>onRemove(entry.id)}>Remove</Button>}</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>;
export function LoraV3ResourcePickerPanel({collectors,config,resolved,targetOptions=[],onCollector,onResource,onAddExternal,onRemove}:{collectors:ResourcePickerCollector[];config:LoraV3Config;resolved:boolean;targetOptions?:LoraV3TargetOption[];onCollector:(id:string)=>void;onResource:(entryId:string,resourceId:string)=>void;onAddExternal:(target:LoraV3Target)=>void;onRemove:(entryId:string,target:LoraV3Target)=>void}){
return <div className="bv-lora-v3-picker-panel">{targetOptions.map(option=><section className="bv-lora-v3-target" key={option.value}><h3>{option.label}</h3><LoraV3ScopePicker collectors={collectors} config={config} target={option.target} resolved={resolved} onCollector={onCollector} onResource={onResource} onAdd={onAddExternal} onRemove={onRemove}/></section>)}</div>;
}
+4 -4
View File
@@ -20,8 +20,8 @@ import { canRemoveGeometry } from "./operationOrder";
import { getWindowSwitchMode } from "../ui/preferences";
import { showBvToast } from "../ui/toastStore";
import { rememberBvWindowInstance } from "../ui/windowActivity";
import { OptionalLoraV3ScopePicker, type LoraV3Config } from "./LoraV3ResourcePickerPanel";
import { loraV3Catalog, loraV3Resolved, readNodeLoraV3Config, setLoraV3Target } from "./loraV3Ui";
import { OptionalLoraV3ScopePicker, type LoraV3Config, type LoraV3Target } from "./LoraV3ResourcePickerPanel";
import { addLoraV3TargetEntry, clearLoraV3Target, loraV3Catalog, loraV3Resolved, readNodeLoraV3Config, removeLoraV3TargetEntry, setLoraV3Collector, setLoraV3EntryResource } from "./loraV3Ui";
type NodeRef = { id: number | string; title?: string; widgets?: Array<{ name: string; value: unknown; callback?: (value: unknown) => void }>; graph?: { setDirtyCanvas?: (a: boolean, b: boolean) => void } };
const keyFor=(node:NodeRef|null|undefined)=>scopedNodeKey((getApp() as any).rootGraph??(getApp() as any).graph,node);
@@ -330,8 +330,8 @@ export default function RegionalEditor({ open, activationToken=0, nodes, initial
const loraWarnings = loraV3Config.entries.length&&(!node||!loraV3Resolved(node,loraV3Config))?["V3 LoRA collector link is unresolved"]:legacyLoraWarnings;
const loraSummary = loraV3Config.entries.length?`${loraV3Config.entries.length} V3 LoRA assignment${loraV3Config.entries.length===1?"":"s"}`:documentValue ? bindingSummary(loraBindings, loraStacks, Object.fromEntries(documentValue.regions.map(region => [region.id, region.name]))) : "";
const loraV3Collectors=node?loraV3Catalog(node).filter(item=>item.resources.length>0):[],loraV3IsResolved=node?loraV3Resolved(node,loraV3Config):false;
const setV3Target=(target:{scope:"global"}|{scope:"region";document_id:string;region_id:string},collectorId:string|null,resourceId:string|null)=>{if(node)setLoraV3Config(setLoraV3Target(node,loraV3Config,target,collectorId,resourceId));};
const optionsPanel = (mode: "selection" | "region" | "document") => documentValue ? <OptionsPanel mode={mode} region={selectedRegion} layer={selectedLayer} bounds={selectionBounds} canvas={documentValue.canvas} automaticRegionColor={selectedRegion ? automaticRegionColor(documentValue.regions.indexOf(selectedRegion)) : null} globalPrompts={documentValue.prompts.global} backgroundPrompts={documentValue.prompts.background} negativeMode={documentValue.negative_mode} promptSections={viewState.promptSections} loraBindings={loraBindings} loraStacks={loraStacks} loraV3GlobalEditor={<OptionalLoraV3ScopePicker label="Global LoRA Stack" help="Applied globally and inherited by every region." collectors={loraV3Collectors} config={loraV3Config} target={{scope:"global"}} resolved={loraV3IsResolved} onValue={(collectorId,resourceId)=>setV3Target({scope:"global"},collectorId,resourceId)}/>} loraV3RegionEditor={selectedRegion?<OptionalLoraV3ScopePicker label="Additional LoRA Stack" help="Added to the global stack for this region." collectors={loraV3Collectors} config={loraV3Config} target={{scope:"region",document_id:documentValue.document_id,region_id:selectedRegion.id}} resolved={loraV3IsResolved} onValue={(collectorId,resourceId)=>setV3Target({scope:"region",document_id:documentValue.document_id,region_id:selectedRegion.id},collectorId,resourceId)}/>:null} onGlobalLoraStack={setGlobalLoraStack} onRegionLoraStack={setRegionLoraStack} onPromptSection={(section, open) => setViewState(current => ({ ...current, promptSections: { ...current.promptSections, [section]: open } }))} onNegativeMode={negativeMode => mutate(value => { value.negative_mode = negativeMode; })} onGlobalPrompts={prompts => mutate(value => { value.prompts.global = prompts; })} onBackgroundPrompts={prompts => mutate(value => { value.prompts.background = prompts; })} onRegion={updateRegion} onLayerBounds={bounds => selectedLayerId && updateLayer(selectedLayerId, (operations, region) => { const replacements = new Map(setLayerBounds(operations, bounds, documentValue.canvas).map(item => [item.id, item])); region.geometry = region.geometry.map(item => replacements.get(item.id) ?? item); })} onBrushSetting={(field, setting) => selectedLayerId && updateLayer(selectedLayerId, operations => operations.forEach(item => { if (item.type === "brush_stroke") Object.assign(item, { [field]: setting }); }))}/> : null;
const v3ScopeProps={collectors:loraV3Collectors,config:loraV3Config,resolved:loraV3IsResolved,onCollector:(id:string)=>node&&setLoraV3Config(setLoraV3Collector(node,loraV3Config,id)),onResource:(entryId:string,id:string)=>node&&setLoraV3Config(setLoraV3EntryResource(node,loraV3Config,entryId,id)),onAdd:(target:LoraV3Target)=>node&&setLoraV3Config(addLoraV3TargetEntry(node,loraV3Config,target)),onRemove:(entryId:string,target:LoraV3Target)=>node&&setLoraV3Config(removeLoraV3TargetEntry(node,loraV3Config,entryId,target)),onClear:(target:LoraV3Target)=>node&&setLoraV3Config(clearLoraV3Target(node,loraV3Config,target))};
const optionsPanel = (mode: "selection" | "region" | "document") => documentValue ? <OptionsPanel mode={mode} region={selectedRegion} layer={selectedLayer} bounds={selectionBounds} canvas={documentValue.canvas} automaticRegionColor={selectedRegion ? automaticRegionColor(documentValue.regions.indexOf(selectedRegion)) : null} globalPrompts={documentValue.prompts.global} backgroundPrompts={documentValue.prompts.background} negativeMode={documentValue.negative_mode} promptSections={viewState.promptSections} loraBindings={loraBindings} loraStacks={loraStacks} loraV3GlobalEditor={<OptionalLoraV3ScopePicker {...v3ScopeProps} target={{scope:"global"}}/>} loraV3RegionEditor={selectedRegion?<OptionalLoraV3ScopePicker {...v3ScopeProps} target={{scope:"region",document_id:documentValue.document_id,region_id:selectedRegion.id}}/>:null} onGlobalLoraStack={setGlobalLoraStack} onRegionLoraStack={setRegionLoraStack} onPromptSection={(section, open) => setViewState(current => ({ ...current, promptSections: { ...current.promptSections, [section]: open } }))} onNegativeMode={negativeMode => mutate(value => { value.negative_mode = negativeMode; })} onGlobalPrompts={prompts => mutate(value => { value.prompts.global = prompts; })} onBackgroundPrompts={prompts => mutate(value => { value.prompts.background = prompts; })} onRegion={updateRegion} onLayerBounds={bounds => selectedLayerId && updateLayer(selectedLayerId, (operations, region) => { const replacements = new Map(setLayerBounds(operations, bounds, documentValue.canvas).map(item => [item.id, item])); region.geometry = region.geometry.map(item => replacements.get(item.id) ?? item); })} onBrushSetting={(field, setting) => selectedLayerId && updateLayer(selectedLayerId, operations => operations.forEach(item => { if (item.type === "brush_stroke") Object.assign(item, { [field]: setting }); }))}/> : null;
return <>{keptNodeIds.map(id=>{const kept=nodes.find(item=>keyFor(item)===id);return kept?<BvMinimizedWindow key={id} title={`Regional Editor · ${kept.title||"BV Regional Prompt"} · #${kept.id}`} onRestore={()=>navigateNode(id,false,false)} onClose={()=>setKeptNodeIds(ids=>ids.filter(value=>value!==id))}/>:null})}<BvManagedWindow open={open} activationToken={activationToken} title="BV Regional Editor" menuVisible={menuVisible} onMenuVisible={visible=>setWindowMenuVisible(node,visible)} context={<BvWindowNavigator label="Regional prompt document" value={keyFor(node)} onNavigate={navigateNode} options={nodes.filter(item=>windowMenuVisible(item)).map(item => ({ value:keyFor(item), label: `${item.title || "BV Regional Prompt"} · #${item.id}` }))}/>} center={<EditorMenus openMenu={viewState.openMenu} onOpenMenu={updateOpenMenu} displayOpacity={displayOpacity} backgroundOpacity={backgroundOpacity} isolate={isolate} binaryMaskPreview={viewState.binaryMaskPreview} hasSelection={!!selectedLayerId} onDisplayOpacity={setDisplayOpacity} onBackgroundOpacity={setBackgroundOpacity} onToggleIsolate={() => setIsolate(value => !value)} onToggleBinaryMaskPreview={() => { cancelGesture(); setViewState(current => ({ ...current, binaryMaskPreview: !current.binaryMaskPreview })); }} onExportDocument={() => documentValue && downloadJson(`${documentValue.title || "bv-regional"}.json`, documentValue)} onExportRegions={() => documentValue && downloadJson(`${documentValue.title || "bv-regions"}.regions.json`, { schema: "bv.regions", version: 2, canvas: documentValue.canvas, regions: documentValue.regions })} onImportDocument={importDocument} onImportRegions={importRegions} onUndo={undo} onRedo={redo} canUndo={!!history.length} canRedo={!!future.length} canMergeLayers={canMergeLayers} onMergeLayers={mergeLayers} canvas={documentValue?.canvas ?? { width: 1024, height: 1024 }} onCanvas={canvas => mutate(value => { value.canvas = canvas; })}/>} mode={viewState.mode} initialGeometry={windowGeometry} minSize={{width:780,height:520}} className="bv-regional-shell" bodyClassName="bv-regional-window-body" onModeChange={mode=>setViewState(current=>({...current,mode}))} onGeometry={geometry=>setViewState(current=>({...current,windows:{...current.windows,floating:geometry}}))} onClose={onClose} status={documentValue?<><span>{loraWarnings[0] ? `⚠ ${loraWarnings[0]}` : `✓ ${loraSummary}`}</span><span>{documentValue.regions.length} regions · layout adjusted for this session</span></>:null} actions={documentValue?<ResetLayoutButton storageId={`regional-editor:${documentValue.document_id}`} editorType="regional" onClick={() => setDockResetSignal(value => value + 1)}/>:null}>
{error ? <div className="bv-regional-error">{error}</div> : documentValue && <>
<main className="bv-regional-dock-host">
+7 -7
View File
@@ -1,6 +1,6 @@
import { getApp } from "../appHelper.js";
import type { ResourcePickerCollector } from "../ui/components";
import type { LoraV3Config, LoraV3Target, LoraV3TargetOption } from "./LoraV3ResourcePickerPanel";
import { withoutLoraV3Target, type LoraV3Config, type LoraV3Target, type LoraV3TargetOption } from "./LoraV3ResourcePickerPanel";
import { emptyLoraV3Config, parseLoraV3Config, serializeLoraV3Config } from "./loraV3Config";
import { connectLoraConsumerTree, ensureLoraCollectorOutput, ensureLoraConsumerInput, linkedLocalLoraCollector, localLoraCollectors, upstreamLoraTransformer } from "./loraV3Graph";
import { installM0CanvasVisibility } from "./m0VisualProjection";
@@ -37,14 +37,14 @@ export function loraV3TargetOptions(node:any):LoraV3TargetOption[]{
return [{value:"global",label:"Global",target:{scope:"global"}},...(document.regions??[]).map((region:any)=>({value:`region:${document.document_id}:${region.id}`,label:String(region.name||region.id),target:{scope:"region" as const,document_id:String(document.document_id),region_id:String(region.id)}}))];
}
export function loraV3Resolved(node:any,config:LoraV3Config){const linked=linkedLocalLoraCollector(node);return Boolean(linked)&&String(widget(linked,"collector_id")?.value??"")===String(config.collector_id??"");}
export function setLoraV3Target(node:any,config:LoraV3Config,target:LoraV3Target,collectorId:string|null,resourceId:string|null){
const matches=(candidate:Record<string,unknown>)=>candidate.scope===target.scope&&(target.scope==="global"||(candidate.document_id===target.document_id&&candidate.region_id===target.region_id));
const collectorChanged=Boolean(collectorId&&collectorId!==config.collector_id),fallbackResource=collectorChanged?loraV3Catalog(node).find(item=>item.id===collectorId)?.resources[0]?.id:null;
let entries=config.entries.filter(entry=>!entry.targets.some(matches)).map(entry=>collectorChanged&&entry.source.kind==="external"&&fallbackResource?{...entry,source:{...entry.source,resource_id:fallbackResource}}:entry);
if(resourceId)entries=[...entries,{id:crypto.randomUUID(),source:{kind:"external",resource_id:resourceId},targets:[target]}];
const next:LoraV3Config={...config,collector_id:entries.some(entry=>entry.source.kind==="external")?collectorId:null,entries};
function commitLoraV3Config(node:any,next:LoraV3Config){
const choice=loraV3Catalog(node).find(item=>item.id===next.collector_id);connectLoraConsumerTree(node,choice?.node??null);writeNodeLoraV3Config(node,next);return next;
}
export function setLoraV3Collector(node:any,config:LoraV3Config,collectorId:string){const choice=loraV3Catalog(node).find(item=>item.id===collectorId),resourceId=choice?.resources[0]?.id??"";return commitLoraV3Config(node,{...config,collector_id:collectorId||null,entries:config.entries.map(entry=>entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry)});}
export function setLoraV3EntryResource(node:any,config:LoraV3Config,entryId:string,resourceId:string){return commitLoraV3Config(node,{...config,entries:config.entries.map(entry=>entry.id===entryId&&entry.source.kind==="external"?{...entry,source:{...entry.source,resource_id:resourceId}}:entry)});}
export function addLoraV3TargetEntry(node:any,config:LoraV3Config,target:LoraV3Target){const choice=loraV3Catalog(node).find(item=>item.id===config.collector_id)??loraV3Catalog(node)[0],resource=choice?.resources[0];if(!choice||!resource)return config;return commitLoraV3Config(node,{...config,collector_id:choice.id,entries:[...config.entries,{id:crypto.randomUUID(),source:{kind:"external",resource_id:resource.id},targets:[target]}]});}
export function removeLoraV3TargetEntry(node:any,config:LoraV3Config,entryId:string,target:LoraV3Target){return commitLoraV3Config(node,withoutLoraV3Target(config,target,entryId));}
export function clearLoraV3Target(node:any,config:LoraV3Config,target:LoraV3Target){return commitLoraV3Config(node,withoutLoraV3Target(config,target));}
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&&nodeClass(item)==="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};}hideLoraV3Widget(id);ensureLoraCollectorOutput(node);node.__bvRuntimeResourceProvider=true;}
export function installLoraV3ConsumerSlot(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)connectLoraConsumerTree(transformer,collector);}}
-1
View File
@@ -7,7 +7,6 @@ export function ResourcePicker({collectors,collectorId,resourceId,resolved=true,
const collector=collectors.find(item=>item.id===collectorId),resource=collector?.resources.find(item=>item.id===resourceId);
const selected=Boolean(collectorId||resourceId),unresolved=selected&&(!resolved||!collector||!resource);
return <div className={`bv-resource-picker ${unresolved?"is-unresolved":""}`}>
<div className="bv-resource-picker-summary">{unresolved?"Unresolved resource selection":collector&&resource?`${collector.label} / ${resource.label}`:"No resource selected"}</div>
<FieldGrid minimum={130}><SelectField label="Collector" value={collectorId} options={[{value:"",label:"Select collector"},...collectors.map(item=>({value:item.id,label:item.label}))]} onValue={onCollector}/><SelectField label="Resource" value={resourceId} disabled={!collector} options={[{value:"",label:"Select resource"},...(collector?.resources??[]).map(item=>({value:item.id,label:item.label}))]} onValue={onResource}/></FieldGrid>
</div>;
}