6 Commits
204 changed files with 8836 additions and 608 deletions
+1
View File
@@ -2,3 +2,4 @@
.comfyignore
tests/
ui/
.tmp/
+3
View File
@@ -33,3 +33,6 @@ BV_DOCS_PLAN.local.md
# Isolated local ComfyUI audit user roots
/.tmp/codex-node-audit-*/
# Local build, browser regression and acceptance artifacts
/.tmp/
+49 -13
View File
@@ -11,8 +11,9 @@ tools, Subgraph controls and deterministic workflow utilities to ComfyUI.
> direction, architecture, review and real-workflow validation remain human-led.
> [!IMPORTANT]
> **BV Node Pack 1.3.0** adds unified Regional negative-prompt policies, local embedding
> completion, catalog and UI improvements, and Subgraph-aware routing.
> **BV Node Pack 1.4.0** adds
> reference-driven regional editing, one workflow-wide Global LoRA Registry,
> automatic MODEL/CLIP preparation and improved Registry, catalog and Quick Edit UI.
> The [Wiki](https://blackvortexai.github.io/bv_nodepack_wiki/) contains the full
> node reference and task-oriented guides.
>
@@ -39,7 +40,30 @@ Registry review remains open in [issue #217](https://github.com/Comfy-Org/regist
Registry publication, security-review status and the version offered by Manager are
separate states; Manager may offer an older version while review is pending.
### Updating to 1.3.0
### Updating to 1.4.0
- Enable **Global** in the intended LoRA Registry. Only one Registry may be active
across the workflow and its Subgraphs. New Registries start with Global off;
conflicting saved selections require an explicit choice, never a load-order winner.
- The Regional Editor's **Globalen Stack anwenden** checkbox defaults to on.
Turning it off skips automatic Global LoRAs for that editor while preserving
manually selected normal stacks. Supply original MODEL/CLIP inputs when opting
out; already applied weights cannot be undone by this checkbox.
- With **BV Regional Native Conditioning**, connect the source MODEL input and
use its **MODEL output (slot 2)** for the sampler when applying Global LoRAs.
Positive and negative outputs remain in slots 0 and 1. Model-specific attention
nodes prepare their own MODEL/CLIP through the same automatic service.
- Existing basis groups retain their identities, order, strengths and enabled
states inside the Global area. Existing manual global assignments remain
separate; they are not copied into the fixed Global stack.
- Registry dialog changes save immediately to the node configuration, including
Undo/Redo and catalog additions. Invalid drafts keep the last valid saved state.
Save the workflow separately to persist changes to disk.
- Restart ComfyUI and reload the browser after updating both backend and frontend.
The catalog identifies an older running backend instead of presenting missing
routing fields as a compatibility result.
The following safeguards from 1.3.0 still apply:
- Re-enter existing API keys for custom OpenAI-compatible providers through
**Configure API Key** and confirm the displayed destination. Keys are bound to
@@ -152,6 +176,28 @@ provenance are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
The README keeps the four most recent version entries. The complete history is
maintained in the [Wiki changelog](https://blackvortexai.github.io/bv_nodepack_wiki/reference/changelog).
### 1.4.0 — 2026-09-08
- Add reference-driven regional editing with **BV Reference Registry**, inline
`@` references, regional tool controls and the Krea 2 Identity Edit path.
- Add an exclusive workflow-wide Global LoRA Registry and per-editor opt-out,
preserving legacy groups and manual selections. Share occurrence-aware automatic
MODEL/CLIP preparation across attention and Native Conditioning consumers.
- Append Native Conditioning's MODEL input/output while retaining positive and
negative output ordinals; use the returned MODEL with the sampler.
- Support official Krea 2 LoRA key mappings and header-based routing evidence.
Catalog routing distinguishes token candidates, multipass and unknown files;
target-model compatibility remains unknown without a concrete model check.
- Autosave valid Registry edits, catalog additions and Undo/Redo. Keep peer Global
changes synchronized and preserve invalid drafts without replacing valid state.
- Grow Registry nodes to a bounded content height before scrolling, respecting
manual dimensions. Enlarge the catalog and its preview pane and automatically
fill and paginate results. Persist header-check results across restarts, invalidate
them when a file changes, and continue reading local metadata and previews fresh.
- Show validated Civitai links from local metadata and concise routing information.
- Fix Quick Edit spacing and textarea resize reachability through whole-content
scrolling, and improve Smart Pipe/Subgraph lifecycle and sizing behavior.
### 1.3.0 — 2026-09-05
- Unify Regional negative-prompt policies (`auto`, `prompt`, `zero_out`) across
@@ -186,13 +232,3 @@ maintained in the [Wiki changelog](https://blackvortexai.github.io/bv_nodepack_w
for strength scrubbing.
- Remove the temporary titlebar-port Canary after the shared production path
passed the complete regression suite.
### 1.2.1 — 2026-08-30
- Add `BV Inspect Any` for readable in-node previews and string pass-through of
arbitrary values.
- Make BV node widgets follow the active ComfyUI light or dark theme through the
shared widget UI layer.
- Extend `BV Regional Image Save` with an optional Regional Context input and
Civitai-compatible prompt, sampler, model, LoRA and hash metadata while
preserving existing workflows when the input is disconnected.
+189
View File
@@ -0,0 +1,189 @@
# Krea 2 Identity Edit
The edit backend uses one global source image with global and regional positive
and negative prompts. Full Editor and Quick Edit share the global References tool and
the BV UI ImagePicker, including available image previews. Disabling References
preserves its selection. Image content may change while the logical Registry slot
remains stable.
## Workflow
1. Connect an IMAGE to a Reference Registry's multimedia input.
2. In Regional Prompt, enable Global → References and select the source image.
3. Set BV Regional Krea 2 Attention `mode` to `identity_edit`.
4. Connect the compatible Krea VAE and the same target LATENT that feeds the
sampler. BV Empty Latent Random Ratio is supported: empty latents use ComfyUI's
native sampler normalization to the model's channels and spatial resolution.
Already populated latents must have the compatible 16-channel image format.
5. Put the Identity Edit LoRA in the fixed **Global** stack of LoRA Registry and
enable its **Global** switch. Keep **Globalen Stack anwenden** enabled in the
Regional Editor. Attention prepares MODEL and CLIP automatically. Alternatively,
supply a model with the weights already merged. No dedicated edit-weight selector is needed.
6. Describe the edit in the global prompt. An empty negative prompt is still
encoded with the same image for the CFG branch.
`generation` remains the default for old and new workflows. Attention strength,
sampling range and regional LoRA execution mode belong to generation. Identity
Edit prepares each effective stack through ComfyUI's native MODEL/CLIP
LoRA loader, preserving order and separate strengths. Grounding uses the matching patched
CLIP; no conditional WeightHooks are added. This avoids the native hook path's
`weight_scale` enumeration failure on quantized layers, without modifying ComfyUI
or changing regional generation. An empty stack preserves an already merged model.
`edit_fit=fit`
matches Identity Edit v1.2 geometry; `crop` supports the older geometry. Reference
boost defaults to 1 (no dense bias allocation); larger values bias target→source
attention and are limited by the shared dense-mask allocation budget.
## Contracts
- `reference_images` is an optional core/document array, currently at most one
`{collector_id, resource_id, role:"source"}`. IDs identify logical slots, not
image hashes. Copying Registry and Prompt remaps the Collector ID together.
- The RegionalContext remains JSON-only. Both Prompt and Krea consumer use the
existing Registry DG family and execution-local resource provider channels.
Krea derives its Collector channels from the actual connected Regional ancestry
using the existing native Root/Subgraph traversal. No process-global image store
or serialized tensor is introduced.
- Source IMAGE must have batch 1. A single source can condition a target batch.
VAE encoding and latent scaling happen once before sampling. A different sampler
target resolution fails rather than loading the VAE inside the sampler.
- Qwen3-VL receives the original source image capped at 768 pixels and the training
template on both conditioning branches. Only mentions bound to the selected
source are translated to `the source image`. Literal `@` remains literal.
- Edit forwarding uses `[text | source | target]`, frame 1 for source and frame 0
for target, centered continuous RoPE offsets in fit mode, and returns only target
tokens. It deliberately does not install the native-layout regional attention
or token-LoRA patches. Nonempty Background prompts,
zeroed negative conditioning and already patched attention/edit models fail
explicitly. Detailer-only regions remain available downstream.
## Regional edit prompts (experimental)
Draw separate regions over the two hair areas. Use a global preservation instruction
and local edits such as `Change her hair color to blonde.` and
`Change her hair color to red.` Enable Generation usage for both regions.
Each active region runs a complete image-grounded instruction consisting of the
global prompt followed by its local prompt. Negative prompts follow the same rule.
All passes use the same source image, full target geometry and unchanged Identity
Edit forward; there is no image crop or native-layout token routing patch.
ComfyUI blends the predicted denoising outputs using the shared region masks,
represented as `[1,1,height,width]` for Krea's singleton time axis. Positive and
negative conditioning receive identical masks, with `set_area_to_bounds=False`.
Region weight is mask times region strength. The global-only pass uses
`1 - max(clamp(region_weight, 0, 1))` across regions, keeping all pixels covered.
ComfyUI normalizes overlapping weights. Strength below one blends in the global
prediction; above one increases relative weight in overlaps, not absolute hair
color intensity. Disabled, detailer-only, empty-mask and zero-strength
regions do not consume passes or subtract from global coverage. With no active
region the previous global-only conditioning is unchanged.
An empty local prompt is skipped unless its effective LoRA stack differs from
the global stack; such a LoRA-only region overlays the existing prompt passes as
described below.
Extra passes increase encoding/sampling cost. Spatial prediction blending steers
where edits apply; it does not guarantee unchanged pixels outside the regions or
perfect identity preservation.
## Regional edit LoRAs (experimental)
### Independent LoRA regions
A region with empty positive AND negative local text and a different effective
LoRA stack is an independent LoRA overlay. It does not add a competing blank
instruction pass. Each existing prompt pass (including uncovered Global) is split
into `outside = prompt_mask * (1-alpha)` and `inside = prompt_mask * alpha`, where
`alpha = clamp(overlay_mask * region_strength, 0, 1)`. The inside keeps exactly the
same positive/negative instruction and receives the combined LoRA stack. The two
parts preserve the original prompt weight pointwise. Overlay strength above one
saturates coverage; it does not increase the configured LoRA MODEL/CLIP strengths.
For example, overlapping blonde-hair and skin-LoRA-only regions now evaluate the
blonde instruction WITH the skin LoRA in their intersection, instead of averaging
a blonde/no-LoRA prediction and a blank/LoRA prediction. Outside that intersection
the original prompt/stack still applies. Regions containing their own text AND
LoRAs retain the established prompt-pass behavior; normal Generation is unchanged.
Overlays are composed in document order. Common Global entries are removed from
the overlay contribution occurrence-by-occurrence; identical assignments across
scopes use their maximum occurrence count. Intentional duplicate entries within
a stack remain duplicated, and entries with different MODEL/CLIP strengths remain
distinct. Stack order is preserved. Multiple overlapping overlays combine their
local entries; equivalent text/stack passes are merged by adding their masks.
Empty intersections are discarded. More than 64 resulting combinations abort
before CLIP encoding or model-variant preparation, rather than silently dropping
effects. Many overlapping soft overlays can increase memory and execution cost.
### Native execution
Keep Identity/Turbo weights in the active Registry's fixed Global stack.
The shared automatic preparation service applies them to MODEL and CLIP.
Select ordinary LoRAs in the Regional Editor as usual; the existing resolver
defines their effective regional stacks and manual global inheritance/override rules.
Each regional variant starts from the same automatically prepared MODEL/CLIP.
The occurrence ledger prevents repeated application of automatic Global weights. Both positive and negative prompts use the matching
statically patched CLIP and the same reference image. No token-gating mode is used
in Identity Edit, regardless of the generation-only mode selector.
`py/util/regional/edit_lora_passes.py` installs a scoped native
`CALC_COND_BATCH` wrapper when active passes need distinct ordered MODEL stacks
(path, MODEL strength). CLIP stacks have a separate ordered cache, so CLIP-only
differences do not cause additional model switches. Native
`load_models_gpu` activates each static ModelPatcher; the passed native executor
evaluates that group's full-image conditioning. Native mask multipliers are used
to undo per-group normalization and blend all groups in float32, separately for
each CFG branch. The original patcher is loaded again in `finally`, including
after a failed forward. A single effective stack retains the normal sampler path.
For multiple MODEL stacks on a dynamic patcher, preparation uses ComfyUI's native
`get_non_dynamic_delegate()` once. All regional variants share that delegate's
model and backup storage. This avoids repeated AIMDO host-buffer staging during
static stack switches. Single MODEL stacks retain the original dynamic path.
The native delegate API caches its model reference on the source patcher; the
original dynamic model and the delegate may coexist in RAM. This is not a memory
ceiling guarantee. A missing native delegate factory is an error, not a silent
fallback to repeated dynamic staging.
Blend weight/count buffers use one channel and broadcast across output channels.
For a 16-channel latent these buffers occupy one sixteenth of their former size;
prediction buffers and native temporary mask allocation remain full-sized.
This seam avoids the quantized conditional WeightHook path; it does not patch
ComfyUI globally. It can be replaced by a native quantization-compatible scoped
weight API once that API satisfies the same routing and restoration tests.
Only full-image BV conditioning is supported: external areas/default conditioning,
ControlNet, additional models, weight hooks, foreign wrappers, context windows,
recursive sampling and Multi-GPU are rejected. A lock serializes this adapter's
own calls; it does not make ComfyUI's global model manager generally thread-safe.
Place other static MODEL LoRA loaders and model patches before the Attention
node. Downstream weight/object patches are rejected because the captured variants
would otherwise silently omit them. Third-party model lifecycle callbacks are
not covered by this adapter's compatibility contract.
Switching native clones may repeatedly repatch/offload weights and increase
sampling time substantially, especially with quantization or low VRAM. Clones
share the underlying model; this is not a promise of constant VRAM usage or
unchanged pixels outside the assigned region. GPU performance and visual effect
require a real workflow test.
`tests/test_edit_lora_passes.py` covers stack/CLIP identity and baseline handling.
`tests/test_edit_lora_native.py` is an optional separate-process CPU contract:
set `BV_NATIVE_COMFY_ROOT` to a ComfyUI installation and run it with a Python
environment containing that installation's dependencies. It exercises native
quantized static LoRA loading, successive stack switches, CFG/overlap blending,
public wrapper dispatch and failure restoration without loading a full checkpoint.
## Provenance and validation
Training description: [conradlocke/krea2-identity-edit](https://huggingface.co/conradlocke/krea2-identity-edit).
Pixel preparation is adapted from [comfyui-krea2edit commit 86f886d](https://github.com/lbouaraba/comfyui-krea2edit/tree/86f886dac23013d88996e3a2e99093ba44d322fb),
Apache-2.0; license retained in `licenses/comfyui-krea2edit-LICENSE.txt`. The helper
has diagnostic output removed. The BV adapter implements the single-source
execution, validation, conditioning, Registry resolution and wrapper integration.
No dependency on installation of the upstream custom node is required.
CPU contract tests cover reference identity/resolution, disabled configuration,
grounding, UTF-16 mentions, fit/crop geometry, token order, RoPE, boost direction,
preencoding and sampler size drift. These do not establish real model output
quality or successful GPU sampling with a particular checkpoint/LoRA combination.
+143
View File
@@ -1,9 +1,70 @@
# BV node presentation architecture
## Automatic Global Registry presentation
The shared BVUI Registry node and editor render the fixed automatic Global stack
with the label `Global · Automatically applied` and explanation `Applied to the
entire generation. Regional stacks are added on top.` The fixed group cannot be
renamed, duplicated or removed; its switch and each entry remain editable.
Migrated basis groups retain their identities and enabled state, are labeled as
Global migration groups and cannot create additional basis groups by duplication.
`loraV3Graph.ts` excludes both automatic roles from ordinary stack pickers only;
their resources remain in the existing provider contract.
`loraGlobalRegistry.ts` reads the existing workflow Registry inventory and owns
exclusive Global switch-change planning. Saved conflicts remain visible. Inventory
storage applies an explicit activation to all affected Registry widgets; their
inline BVUI views refresh, and open workbenches synchronize the Global flag while
preserving other draft fields. Configure does not choose a winning Registry.
`GlobalLoraApplyControl` uses the shared `CheckboxField` with exactly
`Globalen Stack anwenden`, default checked. `OptionsPanel` places it immediately
above Global prompt editing; Quick Edit uses the same component. No Registry
names or IDs accompany it. The old large provider block is absent from Regional
Editor. `loraV3Ui.tsx` unions the active workflow Registry with existing manual
provider dependencies through the existing DG lifecycle. Inventory subscriptions
are removed on node removal. No new ports, hover, geometry or resize exception is
introduced. Native Conditioning still appends MODEL after its sidecar inputs and
output MODEL at index 2; positive/negative remain 0/1.
This document is the authoritative contract for BV node and widget presentation.
The machine-readable exception registry is `PRESENTATION_EXCEPTIONS` in
`ui/src/regional/nodePresentation.ts`.
## Native slot identity and configure transactions
`js/bv_node_slots.js` owns the shared native-slot mechanics used by the
Smart Pipe domain adapter: in-place updates, deterministic identity reuse,
saved physical order, explicit identity serialization, duplicate-removal planning,
and validation of physical-to-execution output mappings. Native slot objects and
their prototypes remain intact. Labels and compatible types are not identity.
Smart Pipe supplies stable schema IDs and canonical `v_NNN` / `out_NNN` names.
Old snapshots without custom IDs can resolve through an exact canonical name.
Unknown or conflicting identities and multiply connected duplicate ports fail
explicitly; reconciliation does not choose a connection to discard. Unconnected
duplicate removal uses one descending index plan per direction.
Native `configure` replays connection notifications before `onConfigure`.
The shared synchronous configure transaction distinguishes those notifications
from user actions. Smart Pipe does not promote slots, change visibility, or
propagate during replay. Its configure adapter restores serialized physical slot
order, rehydrates reserved native action markers, and schedules propagation after
the transaction. The transaction flag is restored in `finally`, including errors.
Native array merging can retain trailing factory slots after copying a compact
snapshot. During that transaction only, a complete prefix matching the saved
names and link references is authoritative; restoration retains those native
objects and removes the unsaved tail from the array without disconnecting links.
Duplicate names inside the snapshot itself remain an error. Missing serialized
custom IDs are cleared from reused prefix objects before canonical-name recovery.
Visible output indices are not backend ordinals. Before publishing an API prompt,
every referenced Smart Pipe output index must have a unique schema mapping;
missing mappings fail instead of silently passing a compact UI index to the
backend. The reserved `pipe` output remains unique at index zero. The existing
LoRA Registry released-order migration in `nodeOutputCompatibility.ts` retains
its separate, explicitly recognized migration contract.
## Non-negotiable rules
1. Every new BV UI surface uses the shared BV UI pack and its tokens/components.
@@ -13,6 +74,15 @@ The machine-readable exception registry is `PRESENTATION_EXCEPTIONS` in
delegate hiding, projection, ordering, measurement, compaction and resizing.
4. Classic, Node Library Ghost and Nodes 2.0 consume the same semantic policy.
5. A connected Legacy port stays visible even when Legacy Debug is disabled.
Unused, hidden Classic Legacy ports do not reserve vertical rows. The shared
port-position projection gives them a temporary finite titlebar position because
native slot ranking excludes explicit `pos`, not `hidden`. Connected ports and
Legacy Debug restore their original positions. The shared interaction boundary
makes hidden Legacy ports transparent to hit tests and composes with later DG
ownership. Serialization restores the original `pos` or omits it, preserving
canonical input/output indices and existing links. No Krea-specific slot offsets
or input reordering are involved.
6. Automatically reconciled internal provider plumbing remains serialized and
executable while hidden. Provider ports that form the explicit interface of
optional manual chains remain visible and direction-aware.
@@ -38,8 +108,26 @@ The machine-readable exception registry is `PRESENTATION_EXCEPTIONS` in
Node-specific code may request these operations. It must not reimplement them.
## Shared media previews
LoRA catalog cards, editor entries and native node rows share BVUI media
previews. Local PNG/JPEG/WebP images retain priority; MP4 is supported when no
image exists. The optional catalog `preview_media_type` defaults to image for
older catalog consumers. Hover previews apply the same live mature/unrated
visibility preference everywhere. Videos play muted and inline only in an open
hover preview; catalog thumbnails remain paused and display a video marker.
Media errors show an explicit unavailable state, reset when the source changes.
## User-controlled height contract
Automatic content refreshes also preserve the current node width while allowing
content-driven growth. JavaScript extensions use `js/bv_node_resize.js`; its
presentation bridge delegates height handling to `presentationSize.ts`. Seed
controls and promoted subgraph widget refreshes use this same entry point.
Never follow native `expandToFitContent()` with an unguarded
`setSize(computeSize())`: that replaces the preserved width with the minimum
content width during reload. Manual resizing remains a native user action.
`presentationSize.ts` is the single owner of persistent user-height state for
Classic, Nodes 2.0 and projected-port layout. A height is persisted when
ComfyUI reports an active resize transaction. Serialization, lifecycle refreshes
@@ -376,3 +464,58 @@ and the actual target backlink agree. Existing IDs/order are retained; no link i
created. Pre-Unpack repairs are restored exactly if native execution throws.
Copy identity replacements are retained for the complete traversal, so every
concrete instance of a shared parent receives its own remapped registry prefix.
## Interactive media fan-in
Internal-state widget policies also classify converted input sockets on Classic
and Nodes 2.0. Such inputs never expose anchors, including in legacy debug mode.
The shared interaction boundary makes these slots transparent during synchronous
native hit tests: temporary position/rectangle exclusion is restored in `finally`.
Public overlapping sockets remain reachable, with original slot identities,
indices, links, and persistent geometry unchanged.
The central `fanIn` role projects native Autogrow inputs onto one visible input
without merging their dependency slots. `interactiveFanIn.ts` routes hit tests to
the first unoccupied native input, rejects occupied destinations, and aligns media
links with the shared visible anchor. Classic and Nodes2 adapters use the same
role; Nodes2 derives its anchor from the rendered native socket. Removal restores
wrapped methods and slot descriptors. Serialization strips transient projected
positions while retaining pre-existing native position data.
`nodePresentationLifecycle.ts` preserves `socketless` only for widgets whose
backend schema explicitly declares it. This compensates for native STRING widget
factories which otherwise discard the flag before creating an input socket. It
does not make ordinary widgets socketless.
The Reference Registry's identity-only Autogrow lifecycle exception is documented
in [Reference Registry](reference-registry.md). It captures serialized identity
before deferred widget hydration can overwrite the live value during native
Undo/Redo. Routing remains in the existing Registry DG implementation.
### Stable native fan-in places
The registered `fan-in-stable-native-places` exception lives in
`ui/src/regional/interactiveFanIn.ts`. For optional native Autogrow inputs with
the central `fanIn` role, a scoped `min` getter returns the highest existing
group ordinal plus two. Native disconnect compaction therefore exits before
moving links, including the last slot when other inputs follow it. The native
maximum remains unchanged. Initial slot creation uses the schema minimum;
`comfyDynamic` is not serialized. The getter follows group object replacement
and restores its original descriptor on cleanup only while still owned by BV.
Callbacks and links remain native. Replace this seam with a supported native
preserve-empty-slots option. See [Reference Registry](reference-registry.md).
Nodes 2.0 removes native hit-test layouts for `display:none` slots without a
visibility-change resync. `ui/src/regional/nodes2NodePresentation.ts` therefore
keeps inactive fan-in rows measurable at the active row's position. They remain
invisible, non-interactive and `aria-hidden`; only one Media port is presented.
The native renderer retains its canonical slot keys and layouts. Shadow classes
and scoped CSS variables are removed on ownership cleanup. Replace this bridge
with native slot-layout invalidation when an official API becomes available.
### LoRA Registry content growth
The shared React node widget host supports opt-in `growWithContent`. The LoRA
Registry grows from its measured content to a 340 px content limit (approximately
440 px including node controls), then scrolls. Explicit user height disables this
intrinsic minimum; existing presentation sizing preserves user width and height.
Other widget hosts retain their existing sizing policy.
+82
View File
@@ -0,0 +1,82 @@
# Reference Registry
`BV Reference Registry` collects native IMAGE, AUDIO and VIDEO values through one
visible `Media` fan-in port. The shared presentation layer projects native V3
Autogrow inputs onto one interactive socket; each connection retains its own
technical input and dependency. The limit is 100 references. Generated upstream
media works like loaded media. There is no Registry configuration window.
## Identity and execution
The socketless `bv.reference_registry_config` v1 widget stores a collector UUID
and `{id, slot}` entries. Reference identity is `(collector_id, resource_id)`.
Display labels are numbered independently by media type: `Image 1`, `Audio 1`,
etc. Labels, link IDs, filenames and content hashes are never identities.
The optional `places` array retains logical slots, including disconnected places.
`entries` contains only connected places. Reconnecting a vacant place preserves
its UUID; new places append. Optional `media_type` preserves per-type numbering
across disconnection. All 100 places, including vacancies, count toward the limit.
No automatic compaction takes place. Source content and filenames do not identify
a place. Reload and Undo/Redo restore configuration and links together. Clipboard
remaps copied collector identities and corresponding prompt mentions while
retaining collector-scoped resource UUIDs. Ambiguous collectors fail closed.
The existing `bv.runtime_resource_provider` v1 envelope carries resource type
`bv-nodepack.reference`, native `resources: {UUID: value}` and
`metadata: {UUID: {name, media_type}}`. An image batch remains one reference.
The Registry does not decode video, load files, concatenate media, hash content or
copy tensors. Resolution checks collector, resource and expected media type.
## Regional Prompt and Registry DG
The provisional Regional catalog-selection panel is hidden. Existing explicit
selections remain supported by the Registry/DG infrastructure.
`reference_v3_config_json` stores `{version: 1, collector_ids: [...]}` with at most
20 unique collectors. Selection uses the shared Registry family resolver,
concrete graph ownership, clipboard remapping and DG route reconciliation.
There is no public Reference Registry-to-consumer provider cable.
The technical `resource_provider` output and
`reference_resource_provider_1` through `_20` inputs are owned by shared provider
presentation and DG infrastructure. The reference family cannot resolve another
registry family. Missing, duplicate or unexpectedly connected collectors fail.
Existing Regional inputs retain their indices; outputs remain `regional` and
`lora_bindings`. Empty selection preserves the existing Regional result.
The `bv-nodepack.references` v1 Regional capability contains only the JSON catalog
`entries: [{collector_id, resource_id, name, media_type}]`, ordered by selected
collector and native input order. Native tensors/audio/video never enter Regional
JSON or PNG metadata. Prompt reference input and available image previews are described in
[Regional editor tools](regional-editor-tools.md). Model conditioning remains
separate; catalog attachment does not apply reference conditioning.
## Shared presentation and native lifecycle
`interactiveFanIn.ts` owns socket positioning, target hit testing, occupied-input
protection and cleanup for the central `fanIn` presentation role. Canonical input
arrays and links remain native. Only explicitly socketless schema widgets receive
the missing native STRING factory option in `nodePresentationLifecycle.ts`.
The registered `reference-autogrow-identity-lifecycle` exception in
`referenceRegistryLifecycle.ts` is needed because native Autogrow replaces slots
and transplants links. It owns identity reconciliation, not presentation or
routing. Serialization is read-only. Stable fan-in places reconcile identity
synchronously inside native connection transactions; catalog publication waits
until native link mutation has finished. The shared native minimum getter prevents compaction independently of callback
installation order. Its original descriptor is restored during cleanup.
Replace this seam when ComfyUI supplies persistent Autogrow entry identities and
a preserve-empty-slots option.
See [central presentation contract](node-presentation.md).
Autogrow stays inside its owning graph. It does not grow enclosing Subgraph
interfaces. Cross-boundary media uses native explicit typed interfaces; collector
transport follows the existing Registry DG boundary machinery.
## Validation
Domain tests cover native media, batches, IDs, strict provider selection and
JSON-only Regional output. Frontend tests cover lifecycle, shared fan-in,
socketless widgets, family isolation and scoped clipboard remapping. Real ComfyUI
API tests are separately available in `test_reference_registry_native.py`.
Native drag, Undo/Redo, clipboard and Nodes2 acceptance remain separate from unit
and build success. Internal runtime evidence is kept outside the public package.
+165
View File
@@ -0,0 +1,165 @@
# Regional attention
## Automatic Global LoRAs
Registry configuration v2 adds one initially empty, fixed `role=global` stack.
Its UI label is **Global · Automatically applied**. Stack and entry switches
remain independent. V1 migration retains ordinary stacks and every old basis
group with its original identity, order, strengths and enabled state. Basis groups
appear as migrated Global groups; no new basis group can be created. Historical
ordinary Global/region assignments and their tool opt-outs remain ordinary scoped
assignments. They are not copied into the fixed Global stack.
Enable the intended Registry's existing Global switch. It is the workflow-wide
selection across Root and nested/shared Subgraphs. An explicit off-to-on change
disables the fixed Global switches on other Registries through the existing
inventory transaction. Fresh Registries start with Global disabled. V1 migration
activates Global only when an enabled legacy basis group contains enabled entries;
persisted v2 switch values remain unchanged, including empty active groups.
Multiple saved active Registries are a visible execution conflict, never resolved
by load order. Switch the intended Registry off and on again, or disable the others.
Copying an active Registry may therefore require this explicit conflict resolution.
Disabling its fixed Global switch suppresses its migrated basis groups as well,
without changing their saved entries or individual group switches.
Regional Editor and Quick Edit show only the compact **Globalen Stack anwenden**
checkbox directly above the Global prompt. It defaults on (`apply_global` omitted
means true). The existing DG routing connects the active Registry automatically;
turning the checkbox off suppresses automatic Global LoRAs for that editor and
preserves all manual stack assignments. No Registry name or ID is shown alongside
this control. Obsolete Prompt-only `registry_ids` are removed during read/migration and ignored
by the Prompt backend; manual entry/step references remain unchanged. The standalone
Regional LoRA Editor retains `registry_ids` and its manual provider UI for compatibility.
These bindings cannot activate an inactive Registry.
The existing v3 LoRA capability carries optional
`automatic: {version: 1, resources: [...]}` provenance separately from manual
`scopes`. Resource records contain original `provider_id`, `resource_id`, `role`,
parallel `entry_ids` and `stack` occurrences. Collector transport reidentification
preserves original Registry identity. Re-materialization refreshes connected
origins and retains upstream origins; clear/replace operations affect only manual
assignments. Runtime resources carry the Registry's `global_enabled` state;
inactive origins do not contribute automatic patches. `automatic.enabled=false`
retains editor opt-out through downstream processing even with no resources.
Already globally patched MODEL/CLIP inputs cannot satisfy that opt-out: consumers
fail with an original-input instruction rather than silently retaining those patches.
`py/util/model_patcher.py` is the shared MODEL/CLIP patch service used by Native
Conditioning, SDXL, Z-Image, FLUX.2 Klein, Krea generation/edit and Anima consumers.
Automatic resources are applied before regional hooks and never enter the manual
scope stacks. Separate MODEL and CLIP occurrence ledgers preserve deliberate repeated
entries even when filenames/strengths match, while repeated paths through Collectors
apply one origin once. The existing Model Patcher is a compatibility adapter to
this same service. Changed occurrences on an already patched input fail rather than
silently accumulating; use original inputs. Old in-memory coarse basis markers
require re-executing the Patcher from original inputs because they contain no
verifiable strength/occurrence information.
Native Conditioning retains positive output 0 and negative output 1, appends
`patched_model` at output 2, and appends optional MODEL after the two old sidecar
inputs. Connect checkpoint MODEL to Native Conditioning MODEL, and its patched
MODEL output to the sampler. MODEL is required when active automatic MODEL LoRAs
exist. Old conditioning-only workflows without such patches remain executable.
The local catalog reads bounded safetensors headers, validates paired ranks and
tensor byte ranges, and distinguishes supported Krea token candidates, required
multipass, and Unknown. Exact target-model compatibility remains Unknown until
execution checks the actual model. Non-spatial time targets require multipass,
which is still regional; they are never silently moved to Global. No current
catalog evidence classifies a file as global-only, so no unsupported Global-move
offer is shown. Civitai links use validated local HTTP(S) model URLs or locally
recorded model/version IDs; no remote metadata or weights are downloaded.
`krea2_lora_names.py` delegates Diffusers aliases to ComfyUI's complete
`comfy.utils.krea2_to_diffusers` mapping. Header evidence, the residual planner
and token injection share this resolver, including time conditioning aliases.
Unavailable official mappings leave catalog compatibility Unknown and never use
a private partial alias fallback.
## Global-only conditioning
All built-in BV regional attention consumers accept a global prompt without
regions or a background prompt. Disabled and detailer-only regions do not require
generation routing.
SDXL, Z-Image, FLUX.2 Klein and Krea 2 retain their existing global slot. Its
unmasked attention covers the entire image; ordinary model validation, negative
conditioning, padding and attention-memory limits remain in effect.
Anima emits native conditioning when no usable regional chain exists. Global and
optional background text are combined per polarity; no artificial region is
created. Model validation still runs, but no regional diffusion wrapper is added.
Both LoRA modes use native global conditioning hooks in this case. Unused regional
LoRAs are excluded; global CLIP and MODEL hooks remain effective.
The adapter represents absence of regions as `None`. The built-in BV consumer
supports this. Third-party consumers of `ANIMA_CONDITIONING_REGIONS` may require
a nonempty chain and are outside this compatibility guarantee.
Global-only compilation retains the regional attention layout. CPU regression tests cover global-only
compilation, negative modes, ineligible regions, unmasked attention and Anima
global-hook dispatch. These tests do not constitute a GPU generation check.
## Krea generation LoRA routing
The `token_gated_singlepass` setting selects hybrid routing without changing the
node's ports. MODEL entries shared by every active scope are applied once through
native static LoRA loading. Matching uses path, MODEL strength and occurrence
count; CLIP strengths remain independently scoped. Explicit empty manual scopes prevent
inherited manual Global application (automatic Registry Global patches remain active). Disabled and empty-mask regions do not contribute, while
LoRA-only regions remain active even without regional prompt text.
Compatible residual files keep token gating. Files containing non-spatial
time-conditioning layers such as `tmlp` and `tproj` are applied completely in
masked native static-weight passes; they are never partially loaded as token
deltas. Incomplete adapters, unknown model targets and invalid shapes raise
errors instead of silently selecting another route. Residual global text uses
an internal scope alias so the token mask cannot override regional opt-outs.
Image coverage follows effective region and background scopes. This limits
direct LoRA application; joint attention can still couple tokens spatially.
Static passes share one native non-dynamic model delegate when multiple MODEL
stacks are needed. They reuse the existing native sampler blending and restore
the original patcher even after failure. The common router is also used by
Identity Edit through its existing adapter and compatibility guards. Hybrid
generation allows its own attention/token wrappers, requires full-image,
single-device conditioning, and rejects foreign control/weight-hook/layout
patches. Apply additional MODEL patches before the Attention node.
The log lists shared static entries, token files, static variant count and the
compatibility reasons. `multipass_legacy` remains explicitly selectable; hybrid
routing does not silently switch to conditional WeightHooks. Automatic Global LoRAs, including migrated basis groups,
are applied by the shared automatic preparation service and preserved through its occurrence ledger. Identity Edit keeps its separate
conditioning and reference-image path.
### Registry editing
The Registry dialog saves each valid edit immediately to its node configuration,
including catalog additions and Undo/Redo. Invalid drafts remain visible with an
unsaved status while the last valid configuration remains executable. External
registry changes are rebased onto draft and history snapshots, preventing Undo
from restoring a superseded Global selection. This does not save a workflow file.
The catalog starts at 1600 × 1050 pixels, subject to the shared window viewport
clamp and any previously saved user geometry.
### Catalog loading and persistent header evidence
Header classification is cached under ComfyUI's user directory at
`default/bv_nodepack/cache/lora_header_checks.json`. Each entry uses canonical
absolute path, file size and nanosecond modification time; the index has an
explicit algorithm version. Bump `HEADER_CHECK_VERSION` when validation or target
mapping changes. A file changed during inspection is not cached. Changes that
preserve both size and modification time are outside this signature contract.
Only header evidence is cached: sidecars, preview revisions and local links are
read again. Actual target-model compatibility remains an execution-time check.
The bounded, validated index is replaced atomically. Missing, corrupt or
unwritable cache files fall back to fresh checks; transient I/O/import failures
are not persisted. Concurrent writers can lose cache additions, resulting only
in extra checks. Without a user-directory provider, no package-local cache is
created.
The shared incremental resource list fills its viewport on mount, after batches,
on resize and on scroll. Frame-coalesced checks stop after sufficient overflow
or exhaustion, and are cancelled on reset/unmount. The manual load button is a
fallback, not a prerequisite for reaching the rest of the catalog.
+33 -1
View File
@@ -56,7 +56,9 @@ removes deleted, disconnected, or retargeted sources.
## Selection semantics
`Last Sent Image` is the default follow-mode sentinel. It resolves to the final
Previously unselected documents start with `Input image`, which follows the
connected preview when available and otherwise displays an empty canvas.
`Last Sent Image` remains an explicit follow-mode option. It resolves to the final
image of the latest accepted publication across all three source kinds. An
explicit selection uses source identity plus batch index; it remains selected
when other sources publish and follows a rerun of the same source/index. If that
@@ -75,3 +77,33 @@ uses the central idempotent deferred-public-input reconciler only after provider
reconciliation. Existing provider slot objects, links, and target indices are
never rebuilt or reordered. The lifecycle seam is registered in
`PRESENTATION_EXCEPTIONS`.
## Explicit image dimensions
`Match image size`, beside the image chooser, applies the selected image's
natural pixel width and height through the existing canvas validation and one
editor history entry. It is disabled while the image is unavailable/loading or
outside the supported 64–16384 pixel bounds. No workflow execution is triggered.
Normalized region geometry is preserved. Undo/Redo restores the canvas dimensions;
stale width/height text drafts are cleared while unrelated drafts remain intact.
The shared `useImageDimensions` observer discards callbacks from previous images
or closed/switched editors. Footer controls wrap as groups in narrow panels.
## Adaptive editor display
The shared `AdaptiveImageCanvas` retains the decoded original and paints only the
visible source crop at the viewport's current zoom and device pixel ratio. Its
paint buffer is limited to 8 million pixels and 4096 pixels per side. This limit
does not include the browser's decoded original image memory. Source mapping
preserves the previous top-left `cover` behavior, including differing canvas and
image aspect ratios. Pending image callbacks and animation frames are cancelled
on source changes or unmount.
The artboard uses viewport-sized SVGs with world-coordinate view boxes instead
of shrinking a document-sized SVG with a CSS scale. Mask/filter bounds include
a softness halo; selection handles may extend beyond the image edge and remain
clipped by the outer viewport. The full scaled pointer target preserves existing
normalized region coordinates. Original images, document dimensions, backend
generation and serialization are unaffected. Geometry tests cover large images,
zoom/pan, high DPR, buffer budgets, cover mapping and offscreen views; actual
interactive performance still requires a live editor check.
+52
View File
@@ -0,0 +1,52 @@
# Regional editor tools and reference authoring
The shared BV UI `SegmentedToggleGroup` exposes independent pressed states on one
line, with an overflow menu for options that do not fit. `ToolTabs` displays only
enabled tools. Changing tabs does not disable tools. Disabling a tool retains its
configuration and filters it out of execution. Legacy documents without explicit
`tool_settings` retain their existing behavior; empty new documents show no tool
panel. Usage retains generation, detailer and both; at least one must remain on.
Both the Regional and Quick Prompt editors use shared `PromptPairFields`.
The `@ references` switch enables suggestions only. Literal artist syntax stays
plain text. Selecting a result inserts a display label and stores UTF-16 offsets
with collector/resource UUIDs. Editing through a mention removes that binding;
edits outside it shift offsets. Missing places remain visible. Catalog changes
close the popup rather than silently changing its selected identity. Each prompt
supports up to 100 mentions. Available native image previews appear in suggestions
and on the stored-reference markers; generated sources may have no preview yet.
Saved mentions are preserved when suggestions are off. Model application is
adapter-specific: [Krea 2 Identity Edit](krea2-identity-edit.md) supports mentions
bound to its selected source image. Other model paths reject unsupported bound
mentions explicitly; disabling suggestions does not remove their bindings.
The Reference tools panel selects the source image through the existing Registry/DG
configuration. Workflow clipboard copies remap
mentions when their registry is included; plain text clipboard contains only text.
Region headings include the current region name with bounded width and tooltip.
All controls and prompt assistance are shared infrastructure; Regional-specific
views supply only domain data and callbacks.
## Connected canvas planning image
The existing canvas image picker offers Input image by default for documents with
no remembered selection. Last Sent Image and explicit history choices remain
available. The shared inputImagePreview resolver reads the actual connected IMAGE
output through native Root/Subgraph boundaries without executing nodes or walking
through image transformations. Native LoadImage uses its current filename widget;
other unambiguous image outputs expose only an available previous preview, labeled
accordingly. Linked filename inputs, missing links, bypassed/muted sources and
ambiguous image outputs have no preview. The editor refreshes the read-only source
at 250 ms intervals while open and releases the timer when closed. No asynchronous
image callback writes preview state. Disconnects clear the source on the next
refresh. Canvas dimensions, regions, tensors and workflow wiring are untouched.
Quick Edit uses the same active-tool resolver, tool tabs and LoRA/LUT pickers as
the full editor. Background shares the global tool scope. Regions expose the
same Generation/Detailer usage toggles and Detailer picker. Tool flags preserve
LoRA/LUT configuration when disabled. Usage reconciliation follows the existing
Full editor contract, which removes Detailer jobs for ineligible regions.
Document writes start from the current widget document; editor activation reloads
all scoped configuration. Tool-tab selection is local presentation state, while
active flags, usage and picker configuration persist across editor switches.
+11
View File
@@ -0,0 +1,11 @@
// Automatic content layout may grow a node, but must preserve its chosen width.
// The presentation bridge also preserves explicit user height when available.
export function resizeNodeToContent(node) {
const size = node?.computeSize?.();
if (!size) return;
const next = [Math.max(Number(node.size?.[0] ?? 0), Number(size[0])), Number(size[1])];
const bridge = globalThis.__bvNodePresentationBridge;
if (bridge?.setAutomaticSize) bridge.setAutomaticSize(node, next);
else node.setSize?.(next);
return next;
}
+154
View File
@@ -0,0 +1,154 @@
// Shared native-slot identity contract. Names are schema keys, never labels/types.
const configuringNodes = new WeakMap();
const configuredPrototypes = new WeakSet();
export const nativeSlotsAreConfiguring = node => (configuringNodes.get(node) || 0) > 0;
export function installNativeSlotConfigureTransaction(nodeType, validate = () => {}) {
const prototype = nodeType.prototype;
if (configuredPrototypes.has(prototype)) return;
const original = prototype.configure;
if (typeof original !== "function") return;
configuredPrototypes.add(prototype);
prototype.configure = function(data, ...args) {
validate(data);
const depth = configuringNodes.get(this) || 0;
configuringNodes.set(this, depth + 1);
try { return original.call(this, data, ...args); }
finally { if (depth) configuringNodes.set(this, depth); else configuringNodes.delete(this); }
};
}
export function updateNativeSlot(slot, values) {
Object.assign(slot, values);
return slot;
}
export function reusableNativeSlotIndex(ports, id, name, identityKey = "bvSlotId") {
const entries = (ports || []).map((slot, index) => ({slot, index}));
const identified = entries.filter(({slot}) => slot[identityKey] === id);
const candidates = identified.length ? identified : entries.filter(({slot}) => !slot[identityKey] && slot.name === name);
return (candidates.find(({slot}) => slot.link != null || Boolean(slot.links?.length)) || candidates[0])?.index ?? -1;
}
export function resolveNativeSlot(slot, definitions, identityKey = "bvSlotId") {
const identity = slot?.[identityKey];
const matches = definitions.filter(definition => identity ? definition.id === identity : definition.name === slot?.name);
if (matches.length !== 1) throw new Error(`Missing or ambiguous slot identity: ${slot?.name}`);
if (identity && definitions.some(definition => definition.name === slot.name && definition.id !== identity)) throw new Error(`Conflicting slot identity: ${slot.name}`);
return matches[0];
}
export function validateSavedSlotIdentities(slots, definitions, identityKey = "bvSlotId") {
const seen = new Set();
for (const slot of slots || []) {
if (!slot[identityKey]) continue;
const definition = resolveNativeSlot(slot, definitions, identityKey);
if (seen.has(definition.id)) throw new Error(`Duplicate saved slot identity: ${definition.id}`);
seen.add(definition.id);
}
}
export function nativeOutputIndexMap(outputs, definitions, reserved = {pipe: 0}) {
const result = {}, identities = new Set();
if (definitions.some(item => !item.id || !Number.isInteger(item.executionIndex) || item.executionIndex < 0 || Object.values(reserved).includes(item.executionIndex))) throw new Error("Invalid execution slot schema");
if (new Set(definitions.map(item => item.id)).size !== definitions.length || new Set(definitions.map(item => item.executionIndex)).size !== definitions.length) throw new Error("Ambiguous execution slot schema");
for (const [name, index] of Object.entries(reserved)) if (outputs?.[index]?.name !== name) throw new Error(`Missing reserved output: ${name}`);
for (const [index, slot] of (outputs || []).entries()) {
if (Object.hasOwn(reserved, slot.name)) {
if (index !== reserved[slot.name] || slot.bvSlotId || (outputs || []).filter(item => item.name === slot.name).length !== 1) throw new Error(`Conflicting reserved output: ${slot.name}`);
result[index] = reserved[slot.name]; continue;
}
const definition = resolveNativeSlot(slot, definitions);
if (identities.has(definition.id)) throw new Error(`Duplicate output identity: ${definition.id}`);
identities.add(definition.id);
result[index] = definition.executionIndex;
}
return result;
}
export function validateNativeOutputLinks(prompt, maps) {
for (const entry of Object.values(prompt || {})) for (const value of Object.values(entry?.inputs || {})) {
if (!Array.isArray(value) || value.length !== 2) continue;
const map = maps[String(value[0])];
if (map && (!Number.isInteger(value[1]) || !Object.hasOwn(map, value[1]))) throw new Error(`Unknown connected output index: ${value[0]}:${value[1]}`);
}
}
export function validateConnectedSlotIdentities(ports, connected, reserved, identityKey = "bvSlotId") {
duplicateNativeSlotRemovals(ports, connected, reserved);
const identities = new Set();
for (const slot of ports || []) {
if (reserved(slot) || !connected(slot)) continue;
const key = slot[identityKey] || slot.name;
if (identities.has(key)) throw new Error(`Ambiguous connected slot identity: ${slot.name}`);
identities.add(key);
}
}
/** Plan all removals first: no stale indices across duplicate groups, no lost links. */
export function duplicateNativeSlotRemovals(ports, connected, reserved = slot => slot.name === "pipe") {
const groups = new Map();
for (const [index, slot] of (ports || []).entries()) {
if (reserved(slot)) continue;
const group = groups.get(slot.name) || []; group.push({slot, index}); groups.set(slot.name, group);
}
const removals = [];
for (const group of groups.values()) {
const linked = group.filter(({slot}) => connected(slot));
if (linked.length > 1) throw new Error(`Ambiguous connected slot: ${group[0].slot.name}`);
const keep = linked[0] || group[0];
for (const item of group) if (item !== keep) removals.push(item.index);
}
return removals.sort((a, b) => b - a);
}
/** Restore saved physical order before the enclosing graph restores link indices. */
export function restoreNativeSlotOrder(node, data, createMissing, identityKey = "bvSlotId") {
const plans = [];
for (const field of ["inputs", "outputs"]) {
if (!Array.isArray(data?.[field])) continue;
const used = new Set();
const names = new Set();
// Native cloneObject copies saved array indices onto the existing backend
// array without truncating it. During configure the matching prefix is the
// authoritative saved contract; trailing factory slots may duplicate names.
const savedPrefix = nativeSlotsAreConfiguring(node) && data[field].every((saved, index) => {
const slot = node[field]?.[index];
if (slot?.name !== saved.name) return false;
return field === "inputs" ? (slot.link ?? null) === (saved.link ?? null)
: JSON.stringify(slot.links || []) === JSON.stringify(saved.links || []);
});
const ordered = data[field].map((saved, index) => {
if (names.has(saved.name)) throw new Error(`Ambiguous saved ${field} slot: ${saved.name}`);
names.add(saved.name);
const matches = savedPrefix ? [node[field][index]] : (node[field] || []).filter(slot => slot.name === saved.name);
if (matches.length > 1 || (matches.length && used.has(matches[0]))) throw new Error(`Ambiguous saved ${field} slot: ${saved.name}`);
if (matches[0]?.[identityKey] && saved[identityKey] && matches[0][identityKey] !== saved[identityKey]) throw new Error(`Conflicting saved slot identity: ${saved.name}`);
if (!matches.length) {
const create = createMissing?.(field, saved);
if (!create) throw new Error(`Missing saved ${field} slot: ${saved.name}`);
if (used.has(saved.name)) throw new Error(`Duplicate saved slot: ${saved.name}`);
used.add(saved.name);
return {create, saved};
}
used.add(matches[0]);
return {slot: matches[0], saved};
});
plans.push({field, ordered, savedPrefix});
}
for (const {field, ordered, savedPrefix} of plans) {
for (const item of ordered) {
if (item.create) item.slot = item.create();
if (item.saved[identityKey]) item.slot[identityKey] = item.saved[identityKey];
else if (savedPrefix) delete item.slot[identityKey];
}
node[field].splice(0, node[field].length, ...ordered.map(item => item.slot));
node[field].forEach((slot, index) => {
if (Object.hasOwn(slot, "slot_index")) slot.slot_index = index;
});
}
}
export function serializeNativeSlotIdentities(node, data, identityKey = "bvSlotId") {
for (const field of ["inputs", "outputs"]) for (const [index, slot] of (node[field] || []).entries()) {
if (slot[identityKey] && data[field]?.[index]) data[field][index][identityKey] = slot[identityKey];
}
}
+42 -42
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -2,6 +2,7 @@
// See PRESENTATION_EXCEPTIONS in ui/src/regional/nodePresentation.ts.
import { app } from "../../scripts/app.js";
import { applySeedAction, materializeSeedControl } from "./bv_seed_model.js";
import { resizeNodeToContent } from "./bv_node_resize.js";
const NODE_CLASS = "BV Seed";
const MIN_WIDTH = 300;
@@ -151,9 +152,7 @@ function migrateExposedSeedName(host, input, inputIndex) {
}
function relayout(node) {
node.expandToFitContent?.();
const size = node.computeSize?.();
if (size) node.setSize?.(size);
resizeNodeToContent(node);
node.setDirtyCanvas?.(true, true);
}
+37 -43
View File
@@ -1,6 +1,7 @@
// Registered presentation exception: dynamic-pipe-slot-structure.
// See PRESENTATION_EXCEPTIONS in ui/src/regional/nodePresentation.ts.
import { app } from "../../scripts/app.js";
import {duplicateNativeSlotRemovals, installNativeSlotConfigureTransaction, nativeSlotsAreConfiguring, nativeOutputIndexMap, restoreNativeSlotOrder, serializeNativeSlotIdentities, validateConnectedSlotIdentities, validateNativeOutputLinks, validateSavedSlotIdentities} from "./bv_node_slots.js";
import { moveMarkedPortToEnd, nextFreeOrdinal, promoteConnectedInheritedSlots, promoteInheritedSlot, resolveLocalSlotNameCollisions, retainedMissingSlots, reusableSmartPipePortIndex, smartPipeSlotName, uniqueSmartPipeSlotName, updateSmartPipePort } from "./bv_smart_pipe_slots.js";
import { mergePipeSchemas, SMART_PIPE_DEFAULT_TITLE } from "./bv_smart_pipe_merge_model.js";
import {
@@ -208,30 +209,20 @@ export function collectExpandedPipeAddresses(rootGraph) {
const relocationHooks = new WeakMap();
export function restoreSerializedPipeSlots(node, data) {
const prepared = {};
for (const field of ["inputs", "outputs"]) {
if (!Array.isArray(data?.[field])) continue;
const slots = node[field] || [];
const names = new Set();
prepared[field] = data[field].map(saved => {
const matches = slots.filter(slot => slot.name === saved.name);
const nativeAction = field === "inputs" && ((saved.name === "bv_add_pipe_source" && saved.type === "BV_SMART_PIPE") || (saved.name === "bv_add_slot" && saved.type === "*"));
if (names.has(saved.name) || matches.length > 1 || (!matches.length && (!nativeAction || typeof node.addInput !== "function"))) throw new Error(`Ambiguous or missing serialized ${field} slot: ${saved.name}`);
names.add(saved.name);
return matches[0] || { createNativeAction: saved };
});
}
for (const [field, slots] of Object.entries(prepared)) {
const resolved = slots.map(slot => {
if (!slot.createNativeAction) return slot;
const saved = slot.createNativeAction;
restoreNativeSlotOrder(node, data, (field, saved) => {
const nativeAction = field === "inputs" && ((saved.name === "bv_add_pipe_source" && saved.type === "BV_SMART_PIPE") || (saved.name === "bv_add_slot" && saved.type === "*"));
if (!nativeAction || typeof node.addInput !== "function") return null;
return () => {
const action = node.addInput(saved.name, saved.type);
action.label = saved.label;
if (saved.name === "bv_add_slot") action.bvAddSlot = true;
else action.bvAddPipeSource = true;
return action;
});
node[field].splice(0, node[field].length, ...resolved);
};
});
for (const input of node.inputs || []) {
if (input.name === ADD_SLOT_NAME) input.bvAddSlot = true;
if (input.name === "bv_add_pipe_source") input.bvAddPipeSource = true;
}
}
@@ -443,15 +434,10 @@ function remapSmartPipeOutputLinks(apiPrompt, routing) {
const outputIndexMaps = {};
for (const descriptor of routing.descriptors) {
const schema = routing.registry[descriptor.address]?.projection?.resolvedSlots || stateFor(descriptor.node).resolvedSlots || [];
const ordinalById = new Map(schema.map((slot) => [slot.id, slot.ordinal]));
const indexMap = { 0: 0 };
for (let index = 1; index < (descriptor.node.outputs?.length || 0); index++) {
const slotId = descriptor.node.outputs[index]?.bvSlotId;
const ordinal = ordinalById.get(slotId);
if (ordinal) indexMap[index] = ordinal;
}
const indexMap = nativeOutputIndexMap(descriptor.node.outputs, schema.map(slot => ({id: slot.id, name: `out_${String(slot.ordinal).padStart(3, "0")}`, executionIndex: slot.ordinal})));
outputIndexMaps[descriptor.executionId] = indexMap;
}
validateNativeOutputLinks(apiPrompt, outputIndexMaps);
return remapPromptOutputLinks(apiPrompt, outputIndexMaps);
}
@@ -769,6 +755,10 @@ function temporarilyRevealOutput(node, slot) {
}
function reconcilePorts(node, schema) {
// Validate connected identity collisions before any structural mutation.
for (const [field, linked] of [["inputs", port => port.link != null], ["outputs", port => Boolean(port.links?.length)]]) {
validateConnectedSlotIdentities(node[field], linked, port => port.name === "pipe" || port.bvAddSlot);
}
const desiredInputs = new Set(schema.filter((slot) => !slot.dormant && (slot.showInput || slot.missing)).map((slot) => `v_${String(slot.ordinal).padStart(3, "0")}`));
const desiredOutputs = new Set(schema.filter((slot) => !slot.dormant && (slot.showOutput || slot.missing || temporarilyRevealOutput(node, slot))).map((slot) => `out_${String(slot.ordinal).padStart(3, "0")}`));
@@ -807,21 +797,8 @@ function reconcilePorts(node, schema) {
for (const kind of ["input", "output"]) {
const ports = kind === "input" ? node.inputs || [] : node.outputs || [];
const indexesByName = new Map();
ports.forEach((port, index) => {
if (port.name === "pipe" || port.bvAddSlot) return;
const indexes = indexesByName.get(port.name) || [];
indexes.push(index);
indexesByName.set(port.name, indexes);
});
for (const indexes of indexesByName.values()) {
if (indexes.length < 2) continue;
const connected = indexes.find((index) => kind === "input" ? ports[index]?.link != null : Boolean(ports[index]?.links?.length));
const keep = connected ?? indexes[0];
for (const index of [...indexes].sort((left, right) => right - left)) {
if (index !== keep) removeSlot(node, kind, index);
}
}
const removals = duplicateNativeSlotRemovals(ports, port => kind === "input" ? port.link != null : Boolean(port.links?.length), port => port.name === "pipe" || port.bvAddSlot);
for (const index of removals) removeSlot(node, kind, index);
}
const addSlots = (node.inputs || []).filter((input) => input.bvAddSlot);
const keepAddSlot = schema.length < MAX_SLOTS;
@@ -993,7 +970,7 @@ function updatePredecessorWidget(node) {
}
export function propagate(node, visited = new Set()) {
if (!node || visited.has(node)) return;
if (!node || visited.has(node) || nativeSlotsAreConfiguring(node)) return;
visited.add(node);
registerUpstream(node, effectiveUpstreamNode(node));
const schema = resolveSchema(node);
@@ -1111,6 +1088,9 @@ function setupNode(node) {
const originalConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (type, index, connected, linkInfo) {
originalConnectionsChange?.apply(this, arguments);
// Native configure replays connection notifications before onConfigure.
// These are restoration events, not user edits of schema visibility/types.
if (nativeSlotsAreConfiguring(this)) return;
if (type === 1 && connected) {
if (convertAddSlot(this, index, linkInfo)) {
requestAnimationFrame(() => propagate(this));
@@ -1395,13 +1375,27 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_CLASS) return;
installNativeSlotConfigureTransaction(nodeType, data => {
const schema = data?.properties?.bvSmartPipe?.resolvedSlots;
if (Array.isArray(schema) && schema.length) {
validateSavedSlotIdentities(data.outputs, schema.map(slot => ({id: slot.id, name: `out_${String(slot.ordinal).padStart(3, "0")}`})));
validateSavedSlotIdentities(data.inputs, schema.map(slot => ({id: slot.id, name: `v_${String(slot.ordinal).padStart(3, "0")}`})));
}
});
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
nodeType.prototype.onConfigure = function (data) {
const result = originalConfigure?.apply(this, arguments);
if (data) restoreSerializedPipeSlots(this, data);
setupNode(this);
requestAnimationFrame(() => propagate(this));
return result;
};
const originalSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = originalSerialize?.apply(this, arguments);
serializeNativeSlotIdentities(this, data);
return result;
};
const originalMenu = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
originalMenu?.apply(this, arguments);
+4 -21
View File
@@ -1,6 +1,8 @@
import {reusableNativeSlotIndex, updateNativeSlot} from "./bv_node_slots.js";
export function updateSmartPipePort(port, label, type, slotId) {
if (port.label === label && port.localized_name === label && port.type === type && port.bvSlotId === slotId) return port;
return { ...port, label, localized_name: label, type, bvSlotId: slotId };
return updateNativeSlot(port, {label, localized_name: label, type, bvSlotId: slotId});
}
export function moveMarkedPortToEnd(ports, predicate) {
@@ -11,26 +13,7 @@ export function moveMarkedPortToEnd(ports, predicate) {
}
export function reusableSmartPipePortIndex(ports, { slotId, portName, label, type }) {
let index = ports?.findIndex((item) => item.bvSlotId === slotId) ?? -1;
if (index >= 0) return index;
const candidates = (ports || []).map((item, candidateIndex) => ({ item, candidateIndex })).filter(({ item }) =>
!item.bvSlotId && item.name !== "pipe" && !item.bvAddSlot && smartPipeTypesAreCompatible(item.type, type)
&& (item.name === portName || item.label === label || item.localized_name === label || item.name === label));
const connected = candidates.find(({ item }) => item.link != null || Boolean(item.links?.length));
if (connected) return connected.candidateIndex;
const exact = candidates.find(({ item }) => item.name === portName);
if (exact) return exact.candidateIndex;
index = candidates[0]?.candidateIndex ?? -1;
if (index < 0) index = ports?.findIndex((item) => !item.bvSlotId && item.name !== "pipe" && !item.bvAddSlot
&& (item.label === label || item.localized_name === label || item.name === label)
&& smartPipeTypesAreCompatible(item.type, type)) ?? -1;
return index;
}
function smartPipeTypesAreCompatible(left, right) {
const leftTypes = String(left || "*").split(",").map((value) => value.trim()).filter(Boolean);
const rightTypes = String(right || "*").split(",").map((value) => value.trim()).filter(Boolean);
return leftTypes.includes("*") || rightTypes.includes("*") || leftTypes.some((candidate) => rightTypes.includes(candidate));
return reusableNativeSlotIndex(ports, slotId, portName);
}
export function retainedMissingSlots(previous, upstreamIds, isUsed) {
+3 -6
View File
@@ -2,6 +2,7 @@
// See PRESENTATION_EXCEPTIONS in ui/src/regional/nodePresentation.ts.
const PRESENTATION_TYPES = new Set(["BV_HEADING", "BV_SPACER", "BV_DIVIDER"]);
import { normalizeDynamicComboSelection } from "./bv_dynamic_combo_model.js";
import { resizeNodeToContent } from "./bv_node_resize.js";
const PRESENTATION_NODE_TYPES = new Map([
["BV Subgraph Heading", "BV_HEADING"],
["BV Subgraph Spacer", "BV_SPACER"],
@@ -277,9 +278,7 @@ export function projectSubgraphUIPresentation(host, visited = new Set()) {
const spacerCallback = function (value) {
this.value = value;
const result = originalCallback?.apply(this, arguments);
host.expandToFitContent?.();
const computedSize = host.computeSize?.();
if (computedSize) host.setSize?.(computedSize);
resizeNodeToContent(host);
host.setDirtyCanvas?.(true, true);
host.graph?.setDirtyCanvas?.(true, true);
return result;
@@ -381,9 +380,7 @@ export function projectSubgraphUIPresentation(host, visited = new Set()) {
const bridge = globalThis.__bvNodePresentationBridge;
if (bridge?.applyClassicSubgraph) bridge.applyClassicSubgraph(host);
else {
host.expandToFitContent?.();
const computedSize = host.computeSize?.();
if (computedSize) host.setSize?.(computedSize);
resizeNodeToContent(host);
}
host.setDirtyCanvas?.(true, true);
host.graph?.setDirtyCanvas?.(true, true);
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2
View File
@@ -1,4 +1,6 @@
{
"BV Image Dimensions": "Read an image's exact aspect ratio and square-area resolution for BV Empty Latent Random Ratio.",
"BV Reference Registry": "Collect native images, audio and video with stable identities for BV Regional Prompt reference catalogs.",
"BV LUT Registry": "Configure and load several named LUTs in one Regional V3 registry node.",
"BV Regional LUT Plan": "Build an ordered LUT plan from BV Regional v3 regions and same-graph resources.",
"BV Apply Regional Enhancement": "Apply a verified prompt-only enhancement to its matching BV Regional source document.",
+32
View File
@@ -0,0 +1,32 @@
from math import gcd, sqrt
class BVImageDimensions:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("ratio", "custom_resolution")
FUNCTION = "read_dimensions"
CATEGORY = "🌀 BV Node Pack/image"
DESCRIPTION = (
"Reads the exact reduced W:H ratio and round(sqrt(width * height)) "
"as an integer square-area resolution. Connect to BV Empty Latent Random "
"Ratio with resolution set to Custom and all standard ratios disabled. "
"Alignment remains controlled by the latent node."
)
def read_dimensions(self, image):
shape = getattr(image, "shape", ())
if len(shape) != 4:
raise ValueError("Expected an IMAGE batch with shape [batch, height, width, channels].")
height, width = int(shape[1]), int(shape[2])
if height <= 0 or width <= 0:
raise ValueError("Image width and height must be positive.")
divisor = gcd(width, height)
return f"{width // divisor}:{height // divisor}", round(sqrt(width * height))
NODE_CLASS_MAPPINGS = {"BV Image Dimensions": BVImageDimensions}
NODE_DISPLAY_NAME_MAPPINGS = {"BV Image Dimensions": "🌀 BV Image Dimensions"}
+29
View File
@@ -0,0 +1,29 @@
from comfy_api.latest import io
from ..util.regional.reference_registry import MAX_REFERENCES, RUNTIME_PROVIDER, build_reference_provider
class BVReferenceRegistryNode(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="BV Reference Registry",
display_name="🌀 BV Reference Registry",
category="🌀 BV Node Pack/regional/references",
description="Collects native images, audio and video, including generated media, with stable reference identities.",
inputs=[
io.String.Input("config_json", default="", multiline=True, dynamic_prompts=False, socketless=True),
io.Autogrow.Input("media", optional=True, template=io.Autogrow.TemplatePrefix(
io.MultiType.Input("media", types=[io.Image, io.Audio, io.Video], optional=True),
prefix="media", min=1, max=MAX_REFERENCES)),
],
outputs=[io.Custom(RUNTIME_PROVIDER).Output("resource_provider")],
)
@classmethod
def execute(cls, config_json, media=None):
return io.NodeOutput(build_reference_provider(config_json, media or {}))
NODE_CLASS_MAPPINGS = {"BV Reference Registry": BVReferenceRegistryNode}
NODE_DISPLAY_NAME_MAPPINGS = {"BV Reference Registry": "🌀 BV Reference Registry"}
+92 -34
View File
@@ -1,4 +1,5 @@
from __future__ import annotations
from ..util.regional.tool_settings import filter_tool_config, filter_legacy_bindings
import hashlib
import json
@@ -46,9 +47,12 @@ from ..util.regional.lora_v3 import (
transform_lora_sequence,
)
from ..util.lora_registry import lora_registry_diagnostics, materialize_lora_registry
from ..util.model_patcher import apply_global_patches
from ..util.regional.lora_v3 import without_lora_provider_selection
from ..util.regional.detailer_v3 import transform_detailer_capability
from ..util.regional.lut_v3 import MAX_LUT_RESOURCE_PROVIDERS, transform_lut_capability
from ..util.regional.v3_contracts import REGIONAL_V3_CAPABILITY_REGISTRY
from ..util.regional.reference_registry import materialize_reference_catalog
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 (
@@ -56,7 +60,6 @@ from ..util.regional.flux2_klein_attention import (
apply_flux2_klein_attention_patch,
)
from ..util.regional.krea2_attention import compile_krea2_attention, apply_krea2_attention_patch
from ..util.regional.krea2_token_lora import apply_krea2_token_lora_patch
from ..util.regional.civitai_metadata import build_regional_metadata, metadata_json
@@ -197,6 +200,8 @@ class BVRegionalPromptNode:
for index in range(1, MAX_LUT_RESOURCE_PROVIDERS + 1)
},
"canvas_image": ("IMAGE", {}),
"reference_v3_config_json": ("STRING", {"default": '{"version":1,"collector_ids":[]}', "multiline": True, "dynamicPrompts": False, "socketless": True}),
**{f"reference_resource_provider_{index}": (RUNTIME_PROVIDER, {"forceInput": True}) for index in range(1, 21)},
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
@@ -210,28 +215,34 @@ class BVRegionalPromptNode:
# are intentionally not wired into an execution branch.
OUTPUT_NODE = True
def build(self, regional_json, lora_bindings_json=None, lora_v3_config_json=None, detailer_v3_config_json=None, lut_v3_config_json=None, resource_provider=None, canvas_image=None, unique_id=None, **providers):
def build(self, regional_json, lora_bindings_json=None, lora_v3_config_json=None, detailer_v3_config_json=None, lut_v3_config_json=None, resource_provider=None, canvas_image=None, unique_id=None, reference_v3_config_json=None, **providers):
document = parse_document(regional_json)
payload = json.loads(lora_v3_config_json or DEFAULT_LORA_V3_JSON)
payload = filter_tool_config(document, json.loads(lora_v3_config_json or DEFAULT_LORA_V3_JSON), "lora")
# Retired Prompt-only provider selection; manual entry dependencies stay intact.
payload.pop("registry_ids", None)
without_lora_provider_selection(payload, _lora_provider_map(resource_provider, **providers))
regional = document
if payload.get("entries"):
regional = transform_lora_capability(
document, normalize_lora_prompt_config(payload), registry=REGIONAL_V3_CAPABILITY_REGISTRY
)
regional = materialize_lora_capability(
regional, _lora_provider_map(resource_provider, **providers), registry=REGIONAL_V3_CAPABILITY_REGISTRY
).to_dict()
materialized = materialize_lora_capability(
regional, _lora_provider_map(resource_provider, **providers), registry=REGIONAL_V3_CAPABILITY_REGISTRY, apply_global=payload.get("apply_global", True)
)
if LORA_CAPABILITY in materialized.capabilities:
regional = materialized.to_dict()
detailer_payload = json.loads(detailer_v3_config_json or DEFAULT_DETAILER_V3_JSON)
if detailer_payload.get("jobs"):
regional = transform_detailer_capability(
regional, detailer_payload, registry=REGIONAL_V3_CAPABILITY_REGISTRY,
).to_dict()
lut_payload = json.loads(lut_v3_config_json or DEFAULT_LUT_V3_JSON)
lut_payload = filter_tool_config(document, json.loads(lut_v3_config_json or DEFAULT_LUT_V3_JSON), "lut")
if lut_payload.get("jobs"):
regional = transform_lut_capability(
regional, lut_payload, registry=REGIONAL_V3_CAPABILITY_REGISTRY,
).to_dict()
result = (regional, reconcile_bindings(lora_bindings_json, document))
regional = materialize_reference_catalog(regional, reference_v3_config_json or '{"version":1,"collector_ids":[]}', providers, registry=REGIONAL_V3_CAPABILITY_REGISTRY)
result = (regional, filter_legacy_bindings(document, reconcile_bindings(lora_bindings_json, document)))
if canvas_image is None:
return result
preview = _preview_regional_canvas_images(canvas_image)
@@ -336,14 +347,13 @@ class BVRegionalLoraNode:
def transform(self, regional, operation, config_json, **providers):
payload = json.loads(config_json) if isinstance(config_json, str) else config_json
without_lora_provider_selection(payload, _ordinal_lora_provider_map(**providers))
transformed = transform_lora_sequence(
regional, payload, registry=LORA_CAPABILITY_REGISTRY, fallback_operation=operation
)
result = transformed.capabilities.get(LORA_CAPABILITY)
if result is not None:
transformed = materialize_lora_capability(
transformed, _ordinal_lora_provider_map(**providers), registry=LORA_CAPABILITY_REGISTRY
)
transformed = materialize_lora_capability(
transformed, _ordinal_lora_provider_map(**providers), registry=LORA_CAPABILITY_REGISTRY
)
return (transformed.to_dict(),)
@@ -620,11 +630,11 @@ class BVRegionalNativeConditioningNode:
),
},
# BV-LEGACY(marked=2026-08-25, remove-after=2026-10-25): V2 LoRA sidecar inputs.
"optional": {"lora_registry": (REGISTRY, {}), "lora_bindings": (BINDINGS, {})},
"optional": {"lora_registry": (REGISTRY, {}), "lora_bindings": (BINDINGS, {}), "model": ("MODEL", {})},
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING")
RETURN_NAMES = ("positive", "negative")
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "MODEL")
RETURN_NAMES = ("positive", "negative", "patched_model")
FUNCTION = "compile"
CATEGORY = CATEGORY_MODEL_GENERIC
DESCRIPTION = (
@@ -633,14 +643,16 @@ class BVRegionalNativeConditioningNode:
)
def compile(self, regional, clip, region_strength_multiplier=1.0, native_composition="blend", hybrid_blend_ratio=0.35,
lora_registry=None, lora_bindings=None):
lora_registry=None, lora_bindings=None, model=None):
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
hook_groups = create_hook_groups(scope_stacks)
return compile_native_conditioning(
positive, negative = compile_native_conditioning(
document, clip, region_strength_multiplier, hook_groups,
composition_mode=native_composition, hybrid_blend_ratio=hybrid_blend_ratio,
)
return positive, negative, model
class BVRegionalSDXLAttentionNode:
@@ -679,6 +691,7 @@ class BVRegionalSDXLAttentionNode:
def apply(self, model, clip, regional, attention_strength, start_percent, end_percent,
lora_registry=None, lora_bindings=None):
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
hook_groups = create_hook_groups(scope_stacks)
@@ -721,6 +734,7 @@ class BVRegionalZImageAttentionNode:
def apply(self, model, clip, regional, attention_strength, start_percent, end_percent,
lora_registry=None, lora_bindings=None):
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
hook_groups = create_hook_groups(scope_stacks)
@@ -761,6 +775,7 @@ class BVRegionalFlux2KleinAttentionNode:
def apply(self, model, clip, regional, attention_strength, start_percent, end_percent,
lora_registry=None, lora_bindings=None):
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
hook_groups = create_hook_groups(scope_stacks)
@@ -799,7 +814,14 @@ class BVRegionalKrea2AttentionNode:
}),
},
# BV-LEGACY(marked=2026-08-25, remove-after=2026-10-25): V2 LoRA sidecar inputs.
"optional": {"lora_registry": (REGISTRY, {}), "lora_bindings": (BINDINGS, {})},
"optional": {
"lora_registry": (REGISTRY, {}), "lora_bindings": (BINDINGS, {}),
"mode": (["generation", "identity_edit"], {"default": "generation"}),
"vae": ("VAE", {}), "target_latent": ("LATENT", {}),
"edit_fit": (["fit", "crop"], {"default": "fit", "tooltip": "Fit for Identity Edit v1.2; crop for older weights."}),
"reference_boost": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.1}),
**{f"reference_resource_provider_{index}": ("BV_RUNTIME_RESOURCE_PROVIDER", {"forceInput": True}) for index in range(1, 21)},
},
}
RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING")
@@ -811,8 +833,11 @@ class BVRegionalKrea2AttentionNode:
"Experimental joint-attention regional routing for Krea 2 Raw and Turbo. "
"Routes the 28 main DiT blocks with a standard KSampler; Krea's four upstream "
"text-fusion blocks remain global. Turbo negatives require a sampler CFG branch. "
"Regional LoRAs default to token-gated single-pass; the previous multi-pass "
"execution remains available as multipass_legacy."
"Default LoRA routing applies shared weights globally, compatible regional files "
"through token gating, and non-spatial adapters through masked static-weight passes. "
"The previous execution remains available as multipass_legacy. Identity Edit mode uses one "
"global Registry image, compatible VAE and the sampler's target latent; "
"load edit weights separately. Regional routing controls apply only in generation mode."
)
def apply(
@@ -826,11 +851,37 @@ class BVRegionalKrea2AttentionNode:
regional_lora_mode="token_gated_singlepass",
lora_registry=None,
lora_bindings=None,
mode="generation",
vae=None,
target_latent=None,
edit_fit="fit",
reference_boost=1.0,
**providers,
):
if regional_lora_mode not in {"multipass_legacy", "token_gated_singlepass"}:
raise ValueError("regional_lora_mode must be multipass_legacy or token_gated_singlepass")
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
if mode not in {"generation", "identity_edit"}:
raise ValueError("Krea mode must be generation or identity_edit")
if mode == "identity_edit":
from ..util.regional.reference_images import resolve_source_image
from ..util.regional.edit_lora_passes import EditLoRAPasses
from ..util.regional.krea2_identity_edit import edit_prompts, validate_inputs, apply_identity_edit
from ..util.regional.krea2_edit_regions import compile_edit_regions
image = resolve_source_image(regional, document, providers)
validate_inputs(image, target_latent, vae, model=model)
edit_prompts(document)
stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
passes = EditLoRAPasses(model, clip, stacks, image)
positive, negative = compile_edit_regions(document, clip, image, scope_encoder=passes.encode, lora_scopes=stacks)
prepared_model = passes.prepare_model()
patched = apply_identity_edit(prepared_model, image, vae, target_latent, fit_mode=edit_fit, ref_boost=reference_boost)
return passes.install(patched, positive, negative), positive, negative
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
if regional_lora_mode == "token_gated_singlepass":
from ..util.regional.krea2_generation_lora import compile_krea2_generation_loras
return compile_krea2_generation_loras(model, clip, document, scope_stacks, attention_strength, start_percent, end_percent)
hook_groups = create_hook_groups(scope_stacks)
positive, negative, slots, aspect_ratio = compile_krea2_attention(
document, clip, hook_groups
@@ -843,10 +894,6 @@ class BVRegionalKrea2AttentionNode:
patched_model = apply_krea2_attention_patch(
model, slots, aspect_ratio, attention_strength, start_percent, end_percent
)
if regional_lora_mode == "token_gated_singlepass":
patched_model = apply_krea2_token_lora_patch(
patched_model, slots, aspect_ratio, document, scope_stacks
)
return patched_model, positive, negative
@@ -935,12 +982,22 @@ class BVRegionalAnimaConditioningNode:
"Update ComfyUI and verify the dependencies reported by the original import error."
) from error
model, clip = apply_global_patches(model, clip, regional)
document = context_document(regional)
scope_stacks = resolve_stack_paths(_consumer_lora_scopes(regional, document, lora_registry, lora_bindings))
hook_groups = create_hook_groups(scope_stacks)
positive, negative, regions, background = compile_anima_adapter(document, clip, hook_groups)
if regions is None:
# Global model LoRAs use native conditioning hooks in either mode;
# token-gated LoRAs require a regional patch that is unnecessary here.
global_document = {**document, "regions": []}
positive, negative = apply_attention_hook_passes(
positive, negative, global_document,
{key: value for key, value in scope_stacks.items() if key == "global"},
{key: value for key, value in hook_groups.items() if key == "global"},
)
# BV-LEGACY(marked=2026-08-25, review-after=2026-10-25): See the INPUT_TYPES removal gate above.
if regional_lora_mode == "multipass_legacy":
if regional_lora_mode == "multipass_legacy" and regions is not None:
positive, negative = apply_attention_hook_passes(
positive, negative, document, scope_stacks, hook_groups
)
@@ -958,7 +1015,7 @@ class BVRegionalAnimaConditioningNode:
self_inject_every_n_blocks=self_inject_every_n_blocks,
background_conditioning=background,
)[0]
if regional_lora_mode == "token_gated_singlepass":
if regional_lora_mode == "token_gated_singlepass" and regions is not None:
patched_model = apply_anima_token_lora_patch(patched_model, scope_stacks)
return patched_model, positive, negative
@@ -1112,13 +1169,14 @@ class BVRegionalImageSaveNode(_BVRegionalImageTargetMixin, SaveImage):
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, width, height
)
try:
parameters, bv_metadata = build_regional_metadata(
regional, prompt=prompt, unique_id=unique_id, width=width, height=height
)
except Exception as error:
print(f"BV Node Pack: Civitai metadata unavailable; saving native metadata only: {error}")
parameters, bv_metadata = None, None
parameters, bv_metadata = None, None
if not args.disable_metadata:
try:
parameters, bv_metadata = build_regional_metadata(
regional, prompt=prompt, unique_id=unique_id, width=width, height=height
)
except Exception as error:
print(f"BV Node Pack: Civitai metadata unavailable; saving native metadata only: {error}")
results = []
for batch_number, image in enumerate(images):
pixels = 255.0 * image.cpu().numpy()
+99
View File
@@ -0,0 +1,99 @@
"""Disposable header evidence cache; metadata and previews are never cached here."""
import json
import os
from pathlib import Path
import tempfile
# Bump when header validation or canonical target mapping changes.
HEADER_CHECK_VERSION = 1
MAX_BYTES = 8 * 1024 * 1024
MAX_ENTRIES = 10000
def default_header_cache_path(folder_paths):
try:
return Path(folder_paths.get_user_directory()) / "default" / "bv_nodepack" / "cache" / "lora_header_checks.json"
except (AttributeError, TypeError, OSError):
return None
def _valid_result(value):
return (isinstance(value, dict) and set(value) == {"status", "family", "reason", "target_model"}
and value["status"] in ("unknown", "token_candidate", "multipass")
and value["family"] in (None, "Krea 2") and value["target_model"] == "unknown"
and isinstance(value["reason"], str) and len(value["reason"]) <= 2048)
class HeaderCheckCache:
"""One catalog request owns an index. Atomic replacement prevents torn writes.
Concurrent requests may lose newly cached entries, causing harmless rechecks.
Cache signatures use canonical path, size and nanosecond modification time.
"""
def __init__(self, path, *, algorithm_version=HEADER_CHECK_VERSION):
self.path = Path(path) if path else None
self.version = algorithm_version
self.entries = {}
self.dirty = False
if self.path is None:
return
try:
with self.path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
if len(raw) > MAX_BYTES:
return
data = json.loads(raw)
if (not isinstance(data, dict) or set(data) != {"version", "entries"}
or type(data["version"]) is not int or data["version"] != self.version
or not isinstance(data["entries"], dict) or len(data["entries"]) > MAX_ENTRIES):
return
for key, entry in data["entries"].items():
if (not isinstance(key, str) or len(key) > 32768 or not isinstance(entry, dict)
or set(entry) != {"size", "mtime_ns", "result"}
or any(type(entry[name]) is not int or entry[name] < 0 for name in ("size", "mtime_ns"))
or not _valid_result(entry["result"])):
return
self.entries = data["entries"]
except (OSError, ValueError, UnicodeError, RecursionError):
pass
def resolve(self, path, checker):
path = Path(path)
stat = path.stat()
key = os.path.normcase(str(path.resolve()))
signature = {"size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
previous = self.entries.get(key)
if previous and all(previous[name] == value for name, value in signature.items()):
return previous["result"].copy()
result = checker(path)
after = path.stat()
if (self.path is not None and _valid_result(result)
and (after.st_size, after.st_mtime_ns) == (stat.st_size, stat.st_mtime_ns)):
if key not in self.entries and len(self.entries) >= MAX_ENTRIES:
self.entries.pop(next(iter(self.entries)))
self.entries[key] = {**signature, "result": result.copy()}
self.dirty = True
return result
def save(self):
if self.path is None or not self.dirty:
return
temporary = None
try:
payload = json.dumps({"version": self.version, "entries": self.entries}, separators=(",", ":")).encode()
if len(payload) > MAX_BYTES:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=self.path.parent, prefix=".lora-header-", delete=False) as stream:
temporary = Path(stream.name)
stream.write(payload)
os.replace(temporary, self.path)
self.dirty = False
except OSError:
pass
finally:
if temporary is not None:
try:
temporary.unlink(missing_ok=True)
except OSError:
pass
+144 -12
View File
@@ -6,17 +6,18 @@ import math
import re
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import quote
from urllib.parse import quote, urlsplit
from uuid import UUID
CONFIG_SCHEMA = "bv.lora_registry_config"
CONFIG_VERSION = 1
CONFIG_VERSION = 2
CATALOG_SCHEMA = "bv.lora_catalog"
CATALOG_VERSION = 1
MAX_SIDECAR_BYTES = 2 * 1024 * 1024
PREVIEW_SUFFIXES = (".preview.png", ".preview.jpg", ".preview.jpeg", ".preview.webp")
MANAGER_PREVIEW_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
VIDEO_PREVIEW_SUFFIXES = (".preview.mp4", ".mp4")
def _uuid(value: Any, label: str) -> str:
@@ -42,8 +43,8 @@ def parse_lora_registry_config(value: Any) -> dict[str, Any]:
candidate = json.loads(value) if isinstance(value, str) else dict(value)
except (json.JSONDecodeError, TypeError, ValueError) as error:
raise ValueError("LoRA registry configuration is invalid JSON") from error
if candidate.get("schema") != CONFIG_SCHEMA or candidate.get("version") != CONFIG_VERSION:
raise ValueError("LoRA registry configuration must be bv.lora_registry_config v1")
if candidate.get("schema") != CONFIG_SCHEMA or candidate.get("version") not in (1, CONFIG_VERSION):
raise ValueError("LoRA registry configuration must be bv.lora_registry_config v1 or v2")
if set(candidate).difference({"schema", "version", "registry_id", "stacks"}):
raise ValueError("LoRA registry configuration contains unsupported fields")
if not isinstance(candidate.get("registry_id"), str):
@@ -58,8 +59,10 @@ def parse_lora_registry_config(value: Any) -> dict[str, Any]:
for stack_index, source_stack in enumerate(source_stacks):
if not isinstance(source_stack, dict):
raise ValueError(f"stacks[{stack_index}] must be an object")
if set(source_stack).difference({"id", "name", "enabled", "entries"}):
if set(source_stack).difference({"id", "name", "enabled", "entries", "role"}):
raise ValueError(f"stacks[{stack_index}] contains unsupported fields")
if source_stack.get("role", "normal") not in ("normal", "basis", "global"):
raise ValueError(f"stacks[{stack_index}].role must be normal, basis or global")
if not isinstance(source_stack.get("id"), str) or not isinstance(source_stack.get("name"), str):
raise ValueError(f"stacks[{stack_index}] id and name must be strings")
if "enabled" in source_stack and not isinstance(source_stack["enabled"], bool):
@@ -110,10 +113,24 @@ def parse_lora_registry_config(value: Any) -> dict[str, Any]:
})
stacks.append({
"id": stack_id,
**({"role": source_stack["role"]} if source_stack.get("role") in ("basis", "global") else {}),
"name": name,
"enabled": source_stack.get("enabled", True) is not False,
"entries": entries,
})
globals_ = [stack for stack in stacks if stack.get("role") == "global"]
if len(globals_) > 1:
raise ValueError("LoRA Registry has more than one automatic Global stack")
if not globals_:
# Stable across frontend/backend normalization and repeated old-workflow reload.
global_id = str(UUID(int=UUID(registry_id).int ^ 1))
if global_id in stack_ids:
raise ValueError("Automatic Global stack identity collides with an existing stack")
name = "Global"
while name.casefold() in stack_names:
name += " · Automatically applied"
enabled = candidate["version"] == 1 and any(stack.get("role") == "basis" and stack["enabled"] and any(entry["enabled"] for entry in stack["entries"]) for stack in stacks)
stacks.insert(0, {"id": global_id, "name": name, "role": "global", "enabled": enabled, "entries": []})
return {"schema": CONFIG_SCHEMA, "version": CONFIG_VERSION, "registry_id": registry_id, "stacks": stacks}
@@ -127,10 +144,14 @@ def lora_registry_diagnostics(value: Any) -> tuple[int, str]:
return 0, "No LoRAs configured"
active_total = 0
summary: list[str] = []
global_enabled = next(stack["enabled"] for stack in config["stacks"] if stack.get("role") == "global")
for stack in config["stacks"]:
active = sum(1 for entry in stack["entries"] if stack["enabled"] and entry["enabled"])
automatic_disabled = stack.get("role") in ("basis", "global") and not global_enabled
active = sum(1 for entry in stack["entries"] if stack["enabled"] and entry["enabled"] and not automatic_disabled)
active_total += active
disabled = " · stack disabled" if not stack["enabled"] else ""
if automatic_disabled and stack.get("role") == "basis":
disabled += " · Registry Global disabled"
summary.append(f'{stack["name"]}: {active}/{len(stack["entries"])} active{disabled}')
return active_total, "\n".join(summary)
@@ -151,15 +172,22 @@ def resolve_lora_path(logical_name: Any, folder_paths_module=None) -> tuple[str,
def materialize_lora_registry(value: Any, folder_paths_module=None) -> tuple[dict[str, Any], str]:
config = parse_lora_registry_config(value)
stacks: dict[str, Any] = {}
global_enabled = next(stack["enabled"] for stack in config["stacks"] if stack.get("role") == "global")
for stack in config["stacks"]:
active: list[tuple[str, float, float]] = []
if stack["enabled"]:
entry_ids = []
if stack["enabled"] and (stack.get("role") not in ("basis", "global") or global_enabled):
for entry in stack["entries"]:
if not entry["enabled"]:
continue
logical, _path = resolve_lora_path(entry["lora_name"], folder_paths_module)
active.append((logical, entry["model_strength"], entry["clip_strength"]))
stacks[stack["id"]] = {"id": stack["id"], "name": stack["name"], "stack": active}
entry_ids.append(entry["id"])
stacks[stack["id"]] = {"id": stack["id"], "name": stack["name"], "stack": active,
"origin_provider_id": config["registry_id"], "entry_ids": entry_ids,
**({"role": stack["role"]} if stack.get("role") in ("basis", "global") else {})}
if stack.get("role") in ("basis", "global"):
stacks[stack["id"]]["global_enabled"] = global_enabled
return {"schema": "bv.lora_stack_registry", "version": 1, "stacks": stacks}, config["registry_id"]
@@ -196,7 +224,7 @@ def _plain_text(value: Any, limit: int = 2000) -> str:
def _preview_path(resolved_lora: Path) -> Path | None:
for suffix in (*PREVIEW_SUFFIXES, *MANAGER_PREVIEW_SUFFIXES):
for suffix in (*PREVIEW_SUFFIXES, *MANAGER_PREVIEW_SUFFIXES, *VIDEO_PREVIEW_SUFFIXES):
candidate = resolved_lora.with_name(f"{resolved_lora.stem}{suffix}")
if candidate.is_file():
return candidate.resolve()
@@ -215,7 +243,7 @@ def _preview_is_safe(metadata: dict[str, Any], cm_info: dict[str, Any]) -> bool:
return cm_info.get("Nsfw") is False
def _catalog_item(logical: str, resolved: Path) -> dict[str, Any]:
def _catalog_item(logical: str, resolved: Path, compatibility_checker=None) -> dict[str, Any]:
metadata = _load_sidecar(resolved.with_name(f"{resolved.stem}.metadata.json"))
cm_info = _load_sidecar(resolved.with_name(f"{resolved.stem}.cm-info.json"))
civitai = metadata.get("civitai") if isinstance(metadata.get("civitai"), dict) else {}
@@ -253,20 +281,123 @@ def _catalog_item(logical: str, resolved: Path) -> dict[str, Any]:
"size": resolved.stat().st_size,
"preview_url": f"/bv_nodepack/loras/preview?name={quote(logical, safe='')}&v={preview_revision}" if preview_revision else None,
"preview_safe": preview_safe,
"preview_media_type": "video" if preview and preview.suffix.casefold() == ".mp4" else "image",
"metadata_sources": sources,
"type": local_type,
"category": category,
"directory": directory,
"civitai_url": _civitai_url(metadata, cm_info),
"compatibility": (compatibility_checker or _header_compatibility)(resolved),
}
def discover_loras(folder_paths_module=None) -> dict[str, Any]:
def _civitai_url(metadata, cm_info):
"""Local LoRA Manager and Comfy metadata only; never use a download URL."""
civitai = metadata.get("civitai") if isinstance(metadata.get("civitai"), dict) else {}
for value in (metadata.get("civitai_url"), metadata.get("url"), cm_info.get("ModelUrl"), cm_info.get("ModelURL"), cm_info.get("Url")):
if not isinstance(value, str) or any(ord(char) < 33 for char in value):
continue
try:
parsed = urlsplit(value)
if (parsed.scheme.lower() in {"http", "https"} and parsed.hostname in {"civitai.com", "www.civitai.com"}
and parsed.username is None and parsed.password is None and parsed.port in {None, 80, 443}
and re.fullmatch(r"/models/[1-9]\d*(?:/[A-Za-z0-9_-]+)?/?", parsed.path)):
return value
except ValueError:
continue
def identifier(*values):
for value in values:
if not isinstance(value, bool) and re.fullmatch(r"[1-9]\d*", str(value)):
return str(value)
return None
model = civitai.get("model") if isinstance(civitai.get("model"), dict) else {}
model_id = identifier(civitai.get("modelId"), model.get("id"), cm_info.get("ModelId"), cm_info.get("ModelID"))
version_id = identifier(civitai.get("id"), cm_info.get("VersionId"), cm_info.get("VersionID"))
if model_id:
return f"https://civitai.com/models/{model_id}" + (f"?modelVersionId={version_id}" if version_id else "")
return None
def _header_compatibility(path, *, raise_transient=False):
"""Bounded header-only routing evidence, never a target-model compatibility claim."""
unknown = {"status": "unknown", "family": None, "reason": "Unknown: target model and adapter support have not been verified.", "target_model": "unknown"}
try:
file_size = path.stat().st_size
with path.open("rb") as stream:
length = int.from_bytes(stream.read(8), "little")
if not 2 <= length <= 8 * 1024 * 1024 or length > file_size - 8:
return unknown
header = json.loads(stream.read(length))
if not isinstance(header, dict):
return unknown
pairs = {}
ranges = []
for key, tensor in header.items():
if key == "__metadata__":
continue
match = re.fullmatch(r"(.+)\.(lora_A\.weight|lora_B\.weight|lora_down\.weight|lora_up\.weight|alpha)", key)
if not match or not isinstance(tensor, dict):
return unknown
shape = tensor.get("shape")
offsets = tensor.get("data_offsets")
if not isinstance(shape, list) or any(not isinstance(n, int) or isinstance(n, bool) or n < 1 for n in shape):
return unknown
if not isinstance(offsets, list) or len(offsets) != 2 or any(not isinstance(n, int) or isinstance(n, bool) for n in offsets) or not 0 <= offsets[0] <= offsets[1] <= file_size - 8 - length:
return unknown
width = {"F16": 2, "BF16": 2, "F32": 4, "F64": 8}.get(tensor.get("dtype"))
if width is None or offsets[1] - offsets[0] != math.prod(shape) * width:
return unknown
ranges.append(tuple(offsets))
part = "alpha" if match[2] == "alpha" else ("down" if match[2] in {"lora_A.weight", "lora_down.weight"} else "up")
group = pairs.setdefault(match[1], {})
if part in group:
return unknown
group[part] = shape
if not pairs:
return unknown
ranges.sort()
if any(left[1] > right[0] for left, right in zip(ranges, ranges[1:])):
return unknown
for group in pairs.values():
down, up = group.get("down", []), group.get("up", [])
if len(down) != 2 or len(up) != 2 or down[0] != up[1] or ("alpha" in group and group["alpha"] not in ([], [1])):
return unknown
from .regional.krea2_lora_names import canonical_krea2_targets
layers = max((int(match[1]) + 1 for key in pairs for match in [re.search(r"(?:blocks[._]|transformer_blocks\.)(\d+)", key)] if match), default=0)
if layers > 256:
return unknown
names = [name.replace(".", "_") for name in canonical_krea2_targets(pairs, layers).values()]
spatial = re.compile(r"(?:blocks_\d+|txtfusion_(?:layerwise|refiner)_blocks_\d+)_(?:attn_(?:wq|wk|wv|gate|wo)|mlp_(?:gate|up|down))|(?:first|last_linear|txtmlp_[13]|txtfusion_projector)")
temporal = {"tmlp_0", "tmlp_2", "tproj_1"}
if len(set(names)) != len(names) or any(not spatial.fullmatch(name) and name not in temporal for name in names):
return unknown
if not any(name.startswith(("blocks_", "txtfusion_")) for name in names):
return unknown
if any(name in temporal for name in names):
return {"status": "multipass", "family": "Krea 2", "reason": "Validated Krea target pairs include non-spatial time conditioning. Regional use requires multipass; target-model shapes are checked at execution.", "target_model": "unknown"}
return {"status": "token_candidate", "family": "Krea 2", "reason": "Validated Krea target pairs are token-gate candidates. Exact target-model compatibility is checked at execution.", "target_model": "unknown"}
except (OSError, ImportError):
if raise_transient:
raise
except (ValueError, UnicodeError, OverflowError, AttributeError, TypeError):
pass
return unknown
def discover_loras(folder_paths_module=None, *, header_cache_path=None) -> dict[str, Any]:
if folder_paths_module is None:
import folder_paths as folder_paths_module
try:
names = folder_paths_module.get_filename_list("loras")
except Exception:
names = []
from .lora_header_cache import HeaderCheckCache, default_header_cache_path
cache = HeaderCheckCache(default_header_cache_path(folder_paths_module) if header_cache_path is None else header_cache_path)
def compatibility(path):
try:
return cache.resolve(path, lambda item: _header_compatibility(item, raise_transient=True))
except (OSError, ImportError):
return {"status": "unknown", "family": None, "reason": "Unknown: target model and adapter support have not been verified.", "target_model": "unknown"}
items: list[dict[str, Any]] = []
seen_names: set[str] = set()
seen_paths: set[Path] = set()
@@ -280,5 +411,6 @@ def discover_loras(folder_paths_module=None) -> dict[str, Any]:
continue
seen_names.add(key)
seen_paths.add(resolved)
items.append(_catalog_item(logical, resolved))
items.append(_catalog_item(logical, resolved, compatibility))
cache.save()
return {"schema": CATALOG_SCHEMA, "version": CATALOG_VERSION, "items": items}
+63
View File
@@ -0,0 +1,63 @@
"""Shared occurrence-aware automatic LoRA preparation for all model consumers."""
from .regional.lora_hooks import apply_static_lora_stack, resolve_stack_paths
OCCURRENCE_MARKER = "bv_automatic_lora_occurrences_v1"
def apply_automatic_resources(model, clip, resources):
"""Apply each registry occurrence exactly once independently to MODEL and CLIP.
A resource may travel through multiple Collectors without changing its origin.
Equal filenames and strengths in distinct entries remain intentional repetitions.
"""
from .regional.lora_v3 import validate_automatic_loras
validate_automatic_loras({"version": 1, "resources": resources})
if len({item["provider_id"] for item in resources}) > 1:
raise ValueError("Multiple active Global LoRA Registries; turn the intended Registry Global switch off and on again, or disable the other Global switches")
model_ledger = dict(model.get_attachment(OCCURRENCE_MARKER) or {}) if model is not None else {}
clip_ledger = dict(clip.patcher.get_attachment(OCCURRENCE_MARKER) or {}) if clip is not None else {}
entries = []
for resource in resources:
for entry_id, (path, ms, cs) in zip(resource["entry_ids"], resource["stack"]):
identity = f'{resource["provider_id"]}:{resource["resource_id"]}:{entry_id}'
strengths = []
for target, ledger, strength in ((model, model_ledger, ms), (clip, clip_ledger, cs)):
signature = (str(path), float(strength))
old = ledger.get(identity)
if old is not None and tuple(old) != signature:
raise ValueError("Automatic LoRA occurrence changed on an already patched input; use the original MODEL/CLIP")
effective = float(strength) if target is not None and old is None else 0.
strengths.append(effective)
if target is not None and strength != 0:
ledger[identity] = signature
if strengths[0] or strengths[1]:
entries.append((path, *strengths))
if not entries:
return model, clip
if model is None and any(item[1] for resource in resources for item in resource["stack"]):
raise ValueError("Automatic Global LoRAs require the Native Conditioning MODEL input and patched MODEL output")
resolved = resolve_stack_paths({"automatic": entries})["automatic"]
result_model, result_clip = apply_static_lora_stack(model, clip, resolved)
if result_model is not None:
result_model.set_attachments(OCCURRENCE_MARKER, model_ledger)
if result_clip is not None:
result_clip.patcher.set_attachments(OCCURRENCE_MARKER, clip_ledger)
return result_model, result_clip
def apply_global_patches(model, clip, regional):
from .regional.lora_v3 import LORA_CAPABILITY, LORA_CAPABILITY_REGISTRY
from .regional.context import normalize_context
context = normalize_context(regional, registry=LORA_CAPABILITY_REGISTRY)
automatic = context.capabilities.get(LORA_CAPABILITY, {}).get("automatic", {})
if automatic.get("enabled", True) is False:
for target in (model, clip.patcher if clip is not None else None):
if target is not None and target.get_attachment(OCCURRENCE_MARKER):
raise ValueError("Global LoRAs are disabled for this editor, but its MODEL/CLIP is already globally patched. Use the original MODEL/CLIP input.")
return model, clip
resources = automatic.get("resources", [])
if len({item["provider_id"] for item in resources}) > 1:
raise ValueError("Multiple active Global LoRA Registries; turn the intended Registry Global switch off and on again, or disable the other Global switches")
if model is None and any(item[1] != 0 for resource in resources for item in resource["stack"]):
raise ValueError("Automatic Global LoRAs require the Native Conditioning MODEL input and patched MODEL output")
return apply_automatic_resources(model, clip, resources) if resources else (model, clip)
+15 -3
View File
@@ -72,7 +72,7 @@ def compile_anima_adapter(
document: Any,
clip: Any,
hooks_by_scope: dict[str, Any] | None = None,
) -> tuple[list, list, AnimaRegionChain, list]:
) -> tuple[list, list, AnimaRegionChain | None, list]:
"""Compile BV_REGIONAL into the Sen-sou Anima patcher's public node contract."""
clean = context_document(document)
@@ -111,9 +111,21 @@ def compile_anima_adapter(
)
if chain is None:
raise ValueError("Anima regional conditioning requires at least one enabled region with a prompt and a non-empty mask")
# Without local routing both scopes cover the whole image. Compile one
# native prompt rather than inventing a full-frame conditioning region.
combined = ", ".join(text for source, text in (
(global_positive_source, global_positive_text),
(background_positive_source, background_positive_text),
) if source.strip())
if combined != base_text:
positive = _encode(clip, combined, scoped_hooks.get("global"))
_, global_negative_text = _prompt_text(clean, "global", "negative")
if chain is None:
global_negative_text = ", ".join(text for source, text in (
_prompt_text(clean, "global", "negative"),
_prompt_text(clean, "background", "negative"),
) if source.strip())
if not use_negative:
negative = _zero_out(positive)
else:
@@ -126,7 +138,7 @@ def compile_anima_adapter(
if background_source.strip():
background[0][1][ANIMA_SCOPED_NEGATIVE] = _encode(
clip, background_text, scoped_hooks.get("background"))
for entry in chain.flatten():
for entry in chain.flatten() if chain is not None else []:
prompts = selection_prompts(_selection(clean, "region", entry.scope))
if prompts[1]["source"].strip():
entry.conditioning[0][1][ANIMA_SCOPED_NEGATIVE] = _encode(
+3
View File
@@ -1142,6 +1142,9 @@ class ApplyAnimaRegionalConditioningPatch:
background_conditioning=None,
):
_validate_anima_model(model)
if regions is None:
# Global-only conditioning needs no regional diffusion wrapper.
return (model,)
model_sampling = model.get_model_object("model_sampling")
start_sigma = float(model_sampling.percent_to_sigma(start_percent))
end_sigma = float(model_sampling.percent_to_sigma(end_percent))
+125 -28
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
import hashlib
import json
import os
from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path
from typing import Any
from threading import Lock
from .context import normalize_context
from .lora_v3 import LORA_CAPABILITY_REGISTRY, materialized_lora_scopes
@@ -15,6 +17,16 @@ from ..prompt.category import ast_to_plain_text, parse_prompt_to_ast
SAMPLER_TYPES = {"KSampler", "KSamplerAdvanced"}
CORE_LORA_TYPES = {"LoraLoader", "LoraLoaderModelOnly"}
# Only verified MODEL output 0 contracts; unknown wrappers/merges stay unknown.
MODEL_LOADERS = {"CheckpointLoaderSimple": "ckpt_name", "CheckpointLoader": "ckpt_name", "UNETLoader": "unet_name"}
MODEL_WRAPPERS = {
"BV Regional SDXL Attention", "BV Regional Z-Image Attention",
"BV Regional FLUX.2 Klein 9B Attention", "BV Regional Krea 2 Attention",
"BV Regional Anima Conditioning", "BV Regional Anima LLLite",
}
HASH_CACHE_MAX_ENTRIES = 128
_HASH_CACHE: OrderedDict[tuple[str, int, int], str] = OrderedDict()
_HASH_CACHE_LOCK = Lock()
def _text(value: Any) -> str:
@@ -81,6 +93,41 @@ def _linked_scalar(prompt: dict[str, Any], value: Any) -> Any:
return value
def _model_provenance(prompt: dict[str, Any], link: Any) -> tuple[str | None, str | None, list[list[Any]]]:
"""Trace MODEL ownership; strengths are loader settings, not CLIP reachability."""
loras: list[list[Any]] = []
seen: set[str] = set()
while isinstance(link, list) and len(link) == 2 and type(link[1]) is int and link[1] == 0:
node_id = str(link[0])
if node_id in seen:
break
seen.add(node_id)
node = prompt.get(node_id)
if not isinstance(node, dict) or not isinstance(node.get("inputs"), dict):
break
kind, inputs = node.get("class_type"), node["inputs"]
if kind in MODEL_LOADERS:
name = inputs.get(MODEL_LOADERS[kind])
if isinstance(name, str) and name.strip():
category = "diffusion_models" if kind == "UNETLoader" else "checkpoints"
return name.strip(), category, list(reversed(loras))
break
if kind in CORE_LORA_TYPES:
name = inputs.get("lora_name")
model_strength = inputs.get("strength_model", 1.0)
clip_strength = inputs.get("strength_clip", 1.0) if kind == "LoraLoader" else 0.0
if not isinstance(name, str) or not name.strip() or not all(
isinstance(value, (int, float)) for value in (model_strength, clip_strength)
):
break
loras.append([name.strip(), model_strength, clip_strength])
elif kind not in MODEL_WRAPPERS:
break
link = inputs.get("model")
# Do not present partial chains as complete provenance.
return None, None, []
def _sampler_info(prompt: Any, unique_id: Any) -> dict[str, Any] | None:
if not isinstance(prompt, dict) or unique_id is None:
return None
@@ -92,20 +139,7 @@ def _sampler_info(prompt: Any, unique_id: Any) -> dict[str, Any] | None:
steps = inputs.get("steps")
if steps is None:
return None
model = next((node for _, node in ancestors if any(key in node.get("inputs", {}) for key in ("ckpt_name", "unet_name", "model_name"))), None)
model_inputs = model.get("inputs", {}) if model else {}
model_name = next((_text(model_inputs.get(key)) for key in ("ckpt_name", "unet_name", "model_name") if _text(model_inputs.get(key))), None)
global_loras: list[list[Any]] = []
for _, node in ancestors:
if node.get("class_type") not in CORE_LORA_TYPES:
continue
lora_inputs = node.get("inputs", {})
lora_name = _text(lora_inputs.get("lora_name"))
if not lora_name:
continue
model_strength = lora_inputs.get("strength_model", 1.0)
clip_strength = lora_inputs.get("strength_clip", model_strength)
global_loras.append([lora_name, model_strength, clip_strength])
model_name, model_category, global_loras = _model_provenance(prompt, inputs.get("model"))
return {
"steps": steps,
"sampler": _text(inputs.get("sampler_name")) or None,
@@ -114,32 +148,74 @@ def _sampler_info(prompt: Any, unique_id: Any) -> dict[str, Any] | None:
"seed": _linked_scalar(prompt, inputs.get("seed", inputs.get("noise_seed"))),
"denoise": inputs.get("denoise"),
"model": model_name,
"model_category": model_category,
"global_loras": global_loras,
}
def _canonical_path(path: str) -> str:
return os.path.normcase(os.path.realpath(os.path.abspath(path)))
def _file_identity(stat: os.stat_result) -> tuple[int, int, int, int]:
return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns
def _sha256(path: str) -> str:
"""Bounded RAM cache; same-size edits preserving mtime cannot be detected.
This is change detection for metadata, not a tamper-proof integrity check.
No persistent cache and no file handles or model data are retained.
"""
canonical = _canonical_path(path)
before = os.stat(canonical)
key = (canonical, before.st_size, before.st_mtime_ns)
with _HASH_CACHE_LOCK:
cached = _HASH_CACHE.get(key)
if cached is not None:
_HASH_CACHE.move_to_end(key)
if cached is not None:
if _file_identity(os.stat(canonical)) != _file_identity(before):
raise OSError("Resource changed while checking cached SHA-256")
return cached
digest = hashlib.sha256()
with open(path, "rb") as handle:
with open(canonical, "rb") as handle:
if _file_identity(os.fstat(handle.fileno())) != _file_identity(before):
raise OSError("Resource changed before hashing")
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
after = os.fstat(handle.fileno())
if _file_identity(after) != _file_identity(before) or _file_identity(os.stat(canonical)) != _file_identity(before):
raise OSError("Resource changed during hashing")
result = digest.hexdigest()
with _HASH_CACHE_LOCK:
_HASH_CACHE[key] = result
_HASH_CACHE.move_to_end(key)
while len(_HASH_CACHE) > HASH_CACHE_MAX_ENTRIES:
_HASH_CACHE.popitem(last=False)
return result
def _resolved_resources(scopes: dict[str, list[list[Any]]], resolver: Callable[[str], str | None], hasher: Callable[[str], str]) -> list[dict[str, Any]]:
resources: dict[str, dict[str, Any]] = {}
attempted: set[str] = set()
for scope, entries in scopes.items():
for name, model_strength, clip_strength in entries:
source = str(name)
path = source if Path(source).is_file() else resolver(source)
if not path or not Path(path).is_file():
continue
canonical = os.path.normcase(os.path.abspath(path))
try:
digest = hasher(path)
except OSError:
canonical = _canonical_path(path)
if canonical not in attempted:
attempted.add(canonical)
try:
digest = hasher(canonical)
except OSError:
continue
resources[canonical] = {"name": Path(path).stem, "path": source, "sha256": digest, "scopes": []}
item = resources.get(canonical)
if item is None:
continue
item = resources.setdefault(canonical, {"name": Path(path).stem, "path": source, "sha256": digest, "scopes": []})
item["scopes"].append({"scope": scope, "model_strength": float(model_strength), "clip_strength": float(clip_strength)})
return list(resources.values())
@@ -158,21 +234,37 @@ def build_regional_metadata(
context = normalize_context(regional, registry=REGIONAL_V3_CAPABILITY_REGISTRY)
core = context.core
scopes = materialized_lora_scopes(context, registry=LORA_CAPABILITY_REGISTRY)
if lora_resolver is None:
sampler = _sampler_info(prompt, unique_id)
if lora_resolver is None or model_resolver is None:
try:
import folder_paths
lora_resolver = lambda name: folder_paths.get_full_path("loras", name)
model_resolver = model_resolver or (lambda name: next((folder_paths.get_full_path(kind, name) for kind in ("checkpoints", "diffusion_models", "unet") if folder_paths.get_full_path(kind, name)), None))
lora_resolver = lora_resolver or (lambda name: folder_paths.get_full_path("loras", name))
model_resolver = model_resolver or (lambda name: folder_paths.get_full_path(sampler["model_category"], name) if sampler and sampler["model_category"] else None)
except Exception:
lora_resolver = lambda _name: None
lora_resolver = lora_resolver or (lambda _name: None)
model_resolver = model_resolver or (lambda _name: None)
if model_resolver is None:
model_resolver = lambda _name: None
sampler = _sampler_info(prompt, unique_id)
if sampler is not None and sampler["global_loras"]:
scopes = dict(scopes)
scopes.setdefault("global", []).extend(sampler["global_loras"])
resources = _resolved_resources(scopes, lora_resolver, hasher)
# Deduplicate across both resource categories for this metadata operation,
# including failed reads; injected hashers receive the same guarantee.
hashes: dict[str, str | OSError] = {}
def hash_once(path: str) -> str:
canonical = _canonical_path(path)
if canonical not in hashes:
try:
hashes[canonical] = hasher(canonical)
except OSError as error:
hashes[canonical] = error
value = hashes[canonical]
if isinstance(value, OSError):
raise value
return value
resources = _resolved_resources(scopes, lora_resolver, hash_once)
positive = _prompt_sections(core, "positive")
negative = _prompt_sections(core, "negative")
parameters = None
@@ -182,7 +274,7 @@ def build_regional_metadata(
model_metadata = {"name": Path(str(sampler["model"])).stem, "path": sampler["model"], "sha256": None}
if model_path and Path(model_path).is_file():
try:
model_metadata["sha256"] = hasher(model_path)
model_metadata["sha256"] = hash_once(model_path)
except OSError:
pass
if sampler is not None:
@@ -220,6 +312,11 @@ def build_regional_metadata(
"model": model_metadata,
"loras": resources,
}
if sampler is not None and sampler["global_loras"]:
metadata["global_lora_strength_semantics"] = (
"Core MODEL-chain LoRA strengths are configured loader values; "
"CLIP-output use by sampler conditioning is not inferred."
)
return parameters, metadata
+4 -4
View File
@@ -180,7 +180,7 @@ def _validate_core_shape(core: Any) -> dict[str, Any]:
if not isinstance(clean, dict):
raise RegionalContextError("core must be an object")
core_keys = {"version", "document_id", "title", "canvas", "prompts", "negative_mode", "overlap", "regions"}
_only_keys(clean, core_keys, "core")
_only_keys(clean, core_keys | {"tool_settings", "reference_images"}, "core")
missing = sorted(core_keys - set(clean))
if missing:
raise RegionalContextError(f"core is missing required fields: {missing}")
@@ -193,11 +193,11 @@ def _validate_core_shape(core: Any) -> dict[str, Any]:
prompts = clean.get("prompts")
if isinstance(prompts, dict):
for scope in ("global", "background"):
_only_keys(prompts.get(scope), {"positive_source", "negative_source"}, f"core.prompts.{scope}")
_only_keys(prompts.get(scope), {"positive_source", "negative_source", "reference_editor", "references"}, f"core.prompts.{scope}")
_only_keys(clean.get("overlap"), {"mode"}, "core.overlap")
regions = clean.get("regions")
if isinstance(regions, list):
region_keys = {"id", "name", "parent_region_id", "enabled", "usage", "strength", "priority", "prompts", "mask", "geometry", "authoring"}
region_keys = {"tool_settings", "id", "name", "parent_region_id", "enabled", "usage", "strength", "priority", "prompts", "mask", "geometry", "authoring"}
geometry_common = {"id", "layer_id", "mask_group_id", "type", "operation", "enabled", "authoring"}
geometry_specific = {
"rect": {"x", "y", "width", "height"}, "ellipse": {"x", "y", "width", "height"},
@@ -209,7 +209,7 @@ def _validate_core_shape(core: Any) -> dict[str, Any]:
_only_keys(region, region_keys, path)
if not isinstance(region, dict):
continue
_only_keys(region.get("prompts"), {"positive_source", "negative_source"}, f"{path}.prompts")
_only_keys(region.get("prompts"), {"positive_source", "negative_source", "reference_editor", "references"}, f"{path}.prompts")
_only_keys(region.get("mask"), {"feather"}, f"{path}.mask")
_only_keys(region.get("authoring"), {"visible", "locked", "color"}, f"{path}.authoring")
geometry = region.get("geometry")
+21
View File
@@ -0,0 +1,21 @@
"""Shared allocation budget for dense regional attention biases."""
import torch
# Preserve the former 96M-entry allowance for one FP16/BF16 mask in bytes.
# Higher batch sizes and wider dtypes consume this same budget, not multiples.
# This limits one bias only: model weights, attention, cached masks, additions
# and other temporary copies remain outside it. It cannot guarantee no OOM.
MAX_DENSE_MASK_BYTES = 96_000_000 * 2
def check_dense_mask_budget(total: int, batch: int, dtype: torch.dtype, label: str) -> None:
required = batch * total * total * torch.empty((), dtype=dtype).element_size()
if required > MAX_DENSE_MASK_BYTES:
raise RuntimeError(
f"{label} regional attention mask would allocate {required / 1024**3:.2f} GiB "
f"(batch={batch}, tokens={total}, dtype={dtype}); "
f"the per-mask budget is {MAX_DENSE_MASK_BYTES / 1024**3:.2f} GiB. "
"Reduce batch size or canvas size, or use BV Regional Native Conditioning"
)
+14
View File
@@ -8,6 +8,7 @@ import math
import uuid
from typing import Any
from .reference_mentions import validate_prompt_references, require_reference_application
from ..prompt.category import ast_to_plain_text, parse_prompt_to_ast
@@ -45,10 +46,18 @@ def _finite_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def _check_tool_settings(value, path, issues):
if value is None:
return
if not isinstance(value, dict) or set(value) - {"lora", "lut", "references"} or any(not isinstance(flag, bool) for flag in value.values()):
issues.append(f"{path} must contain only boolean lora/lut/references switches")
def _check_prompt_pair(value: Any, path: str, issues: list[str]) -> None:
if not isinstance(value, dict):
issues.append(f"{path} must be an object")
return
validate_prompt_references(value, path, issues)
for key in ("positive_source", "negative_source"):
source = value.get(key)
if not isinstance(source, str):
@@ -82,6 +91,9 @@ def validate_document(document: Any, *, executable: bool = True) -> list[str]:
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
issues.append(f"canvas.{key} must be a positive integer")
_check_tool_settings(document.get("tool_settings"), "tool_settings", issues)
from .reference_images import validate_reference_images
validate_reference_images(document.get("reference_images"), issues)
prompts = document.get("prompts")
if not isinstance(prompts, dict):
issues.append("prompts must be an object")
@@ -134,6 +146,7 @@ def validate_document(document: Any, *, executable: bool = True) -> list[str]:
priority = region.get("priority")
if not isinstance(priority, int) or isinstance(priority, bool):
issues.append(f"{path}.priority must be an integer")
_check_tool_settings(region.get("tool_settings"), f"{path}.tool_settings", issues)
_check_prompt_pair(region.get("prompts"), f"{path}.prompts", issues)
mask = region.get("mask")
if not isinstance(mask, dict) or not _finite_number(mask.get("feather")) or not 0 <= mask.get("feather", -1) <= 0.5:
@@ -308,6 +321,7 @@ def selection_prompts(selection: Any) -> tuple[dict[str, Any], dict[str, Any]]:
prompt_pair = next(region["prompts"] for region in document["regions"] if region["id"] == selection["region_id"])
else:
prompt_pair = document["prompts"][selection["scope"]]
require_reference_application(prompt_pair)
positive_ast = parse_prompt_to_ast(prompt_pair["positive_source"])
negative_ast = parse_prompt_to_ast(prompt_pair["negative_source"])
return (
+82
View File
@@ -0,0 +1,82 @@
"""Native static LoRA stacks for full-image, spatially blended edit passes.
Uses the public sampler wrapper and model loading seams, never conditional
WeightHooks. See docs/design/krea2-identity-edit.md for supported boundaries.
"""
import uuid
from .lora_hooks import apply_static_lora_stack
from .static_lora_passes import StaticLoRAPassRouter, prepare_static_models
WRAPPER_KEY = "bv_edit_lora_passes"
PASS_KEY = "bv_edit_lora_pass"
OWNER_KEY = "bv_edit_lora_owner"
class EditLoRAPasses:
def __init__(self, model, clip, stacks, image):
self.model, self.clip, self.stacks, self.image = model, clip, stacks, image
self.models, self.clips, self.keys = [], {}, {}
self.owner = str(uuid.uuid4())
def variant(self, scope, entries=None):
if entries is None:
entries = self.stacks.get(scope, self.stacks.get("global", []))
entries = [(str(path), float(ms), float(cs)) for path, ms, cs in entries if ms != 0 or cs != 0]
key = tuple((path, ms) for path, ms, _ in entries if ms != 0)
clip_key = tuple((path, cs) for path, _, cs in entries if cs != 0) if self.clip is not None else ()
new_model, new_clip = key not in self.keys, clip_key not in self.clips
if new_model and new_clip:
model, clip = apply_static_lora_stack(self.model, self.clip, entries)
elif new_model:
model, _ = apply_static_lora_stack(self.model, None, [(path, ms, 0.) for path, ms in key])
elif new_clip:
_, clip = apply_static_lora_stack(None, self.clip, [(path, 0., cs) for path, cs in clip_key])
if new_model:
self.keys[key] = len(self.models)
self.models.append(model)
if new_clip:
self.clips[clip_key] = clip
return self.keys[key]
def prepare_model(self):
"""Use ComfyUI's native delegate once, outside all sampling steps.
AIMDO's dynamically staged host buffers are unsuitable for repeated
static stack switching. All variants share one non-dynamic base, not
a full checkpoint copy per region. Single MODEL stacks retain AIMDO.
"""
self.variant("global")
self.models = prepare_static_models(self.models)
return self.models[0]
def encode(self, scope, text, *, entries=None):
from .krea2_identity_edit import grounded_encode
self.variant("global")
index = self.variant(scope, entries)
selected = self.stacks.get(scope, self.stacks.get("global", [])) if entries is None else entries
clip_key = tuple((str(path), float(cs)) for path, _, cs in selected if cs != 0) if self.clip is not None else ()
result = grounded_encode(self.clips[clip_key], *text, self.image)
return tuple([[embedding, {**metadata, PASS_KEY: index, OWNER_KEY: self.owner}] for embedding, metadata in branch] for branch in result)
def install(self, model, positive, negative):
if len(self.models) <= 1:
# Preserve the native global/identical-stack path without a wrapper.
for branch in (positive, negative):
for _, metadata in branch:
metadata.pop(PASS_KEY, None)
metadata.pop(OWNER_KEY, None)
return model
import comfy.patcher_extension
result = model.clone()
comfy.patcher_extension.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.CALC_COND_BATCH, WRAPPER_KEY,
StaticEditPassRouter(tuple(self.models), self.owner), result.model_options, is_model_options=True)
return result
class StaticEditPassRouter(StaticLoRAPassRouter):
def __init__(self, models, owner):
from .krea2_identity_edit import require_compatible_patches
super().__init__(models, owner, pass_key=PASS_KEY, owner_key=OWNER_KEY,
validate_options=lambda options: require_compatible_patches(options, installed=True), label="Identity Edit")
+2 -11
View File
@@ -8,13 +8,13 @@ import torch
import torch.nn.functional as F
from .context import context_document
from .dense_attention import check_dense_mask_budget
from .document import region_used_for, selection_prompts
from .mask_renderer import render_selection
from .prompt_policy import use_negative_prompts, zero_encoded, mask_cfg_padding
BACKEND_ID = "flux2_klein_9b_joint_attention"
MAX_DENSE_MASK_ENTRIES = 96_000_000
@dataclass(frozen=True)
@@ -104,9 +104,6 @@ def compile_flux2_klein_attention(
specifications.append(
(region["name"], mask, max(0.0, float(region["strength"])), *prompts)
)
if len(specifications) == 1:
raise ValueError("FLUX.2 Klein attention routing requires a prompted background or region mask")
positive_values, negative_values = [], []
positive_masks, negative_masks = [], []
slots: list[Flux2KleinRegionalSlot] = []
@@ -178,13 +175,7 @@ def build_flux2_joint_attention_bias(
f"FLUX.2 context has {text_tokens} tokens, expected at least {raw_text_tokens}"
)
total = text_tokens + target_image_tokens + reference_image_tokens
entries = total * total
if entries > MAX_DENSE_MASK_ENTRIES:
gib = entries * torch.empty((), dtype=dtype).element_size() / (1024 ** 3)
raise RuntimeError(
f"FLUX.2 regional attention mask would allocate at least {gib:.2f} GiB at this resolution; "
"reduce the canvas size or use BV Regional Native Conditioning"
)
check_dense_mask_budget(total, batch, dtype, "FLUX.2")
grid_h, grid_w = _grid_for_tokens(target_image_tokens, aspect_ratio)
bias = torch.zeros((batch, 1, total, total), device=device, dtype=dtype)
prefix_padding = text_tokens - raw_text_tokens
+2 -11
View File
@@ -9,13 +9,13 @@ import torch.nn.functional as F
from .clip_hooks import clip_with_hooks
from .context import context_document
from .dense_attention import check_dense_mask_budget
from .document import region_used_for, selection_prompts
from .mask_renderer import render_selection
from .prompt_policy import use_negative_prompts, zero_encoded, mask_cfg_padding
BACKEND_ID = "krea2_joint_attention_experimental"
MAX_DENSE_MASK_ENTRIES = 96_000_000
CONDITIONING_WIDTH = 12 * 2560
@@ -136,9 +136,6 @@ def compile_krea2_attention(
(region["name"], region["id"], mask, max(0.0, float(region["strength"])),
positive_prompt, negative_prompt)
)
if len(specifications) == 1:
raise ValueError("Krea 2 attention routing requires a prompted background or region mask")
positive_values: list[torch.Tensor] = []
negative_values: list[torch.Tensor] = []
positive_masks: list[torch.Tensor] = []
@@ -214,13 +211,7 @@ def build_krea2_joint_attention_bias(
if text_tokens < raw_text_tokens:
raise RuntimeError(f"Krea 2 context has {text_tokens} tokens, expected at least {raw_text_tokens}")
total = text_tokens + target_image_tokens + reference_image_tokens
entries = total * total
if entries > MAX_DENSE_MASK_ENTRIES:
gib = entries * torch.empty((), dtype=dtype).element_size() / (1024 ** 3)
raise RuntimeError(
f"Krea 2 regional attention mask would allocate at least {gib:.2f} GiB at this resolution; "
"reduce the canvas size or use BV Regional Native Conditioning"
)
check_dense_mask_budget(total, batch, dtype, "Krea 2")
grid_h, grid_w = _grid_for_tokens(target_image_tokens, aspect_ratio)
bias = torch.zeros((batch, 1, total, total), device=device, dtype=dtype)
prefix_padding = text_tokens - raw_text_tokens
+66
View File
@@ -0,0 +1,66 @@
# Pixel preparation adapted from lbouaraba/comfyui-krea2edit.
# Upstream commit 86f886dac23013d88996e3a2e99093ba44d322fb, Apache-2.0.
# Changes: removed diagnostic output; only pixel preparation is retained.
# See licenses/comfyui-krea2edit-LICENSE.txt and docs/design/krea2-identity-edit.md.
import torch.nn.functional as F
def _fit_encode_image(image, vae, H, W, cache, key, fit_mode="crop"):
"""Pixel-space source prep (blur-proof path): center-crop the IMAGE to the
target AR, resize to the exact target pixel grid, VAE-encode. Latent-space
resizing (the old fallback) softens VAE latents — this path never resizes
latents at all. Cached per target resolution (encode once, not per step)."""
key = key + (fit_mode,)
if key in cache:
return cache[key]
px_h, px_w = H * 8, W * 8
img = image.movedim(-1, 1) # B,H,W,C -> B,C,H,W
ih, iw = img.shape[-2:]
if fit_mode == "fit":
# "bilinear" answer to scale mismatch: resample CONTENT (pixel space, bicubic)
# to the target's grid density instead of moving positions. AR-preserving
# fit-inside, no crop, no grey canvas — the forward places it at an integer
# centered offset (scaled-pos with s=1 -> stride 1, no rounding artifacts).
sc = min(px_h / ih, px_w / iw)
# NEAR-MATCHED AR: fill the target grid EXACTLY via a minimal center-crop.
# Fit-inside margins of 1-2 tokens are not harmless: target edge columns
# with no ref correspondence get filled by repeating adjacent ref content
# (2026-07-14 edge-duplication bug: ref (74,54) vs target (74,56)).
# This also restores the design promise fit == crop at matched AR.
CROP_TOL = 0.08
if ih * sc >= px_h * (1 - CROP_TOL) and iw * sc >= px_w * (1 - CROP_TOL):
s = max(px_h / ih, px_w / iw)
ch, cw = min(ih, int(round(px_h / s))), min(iw, int(round(px_w / s)))
y0, x0 = (ih - ch) // 2, (iw - cw) // 2
img = img[..., y0:y0 + ch, x0:x0 + cw]
nh, nw = px_h, px_w
else:
# genuine AR mismatch: MUST match the trainer's _fit_prep EXACTLY
# (krea2_edit.py) — /16 floor snap capped at the target's /16 floor.
# The model is trained on this geometry; a /8-round node grid would
# produce a different ref latent size -> different centered offset ->
# a visible margin-boundary seam even from a well-trained model
# (train/infer geometry must be byte-identical). 2026-07-15 alignment.
nh = min(max(16, int(ih * sc) // 16 * 16), max(16, px_h // 16 * 16))
nw = min(max(16, int(iw * sc) // 16 * 16), max(16, px_w // 16 * 16))
# CROP-TO-GRID (2026-07-28 seam-doubling RCA): resizing ih*sc -> floor16
# SQUASHES content by up to 15px; the misregistration peaks exactly at
# the ref band edges — the outpaint seam — and renders as a doubled
# band (proven causal: 754px vs 753px input A/B, one pixel flips
# clean<->worst). Center-crop the source so the fitted axis lands on
# the /16 grid at scale sc EXACTLY: zero squash, stride-1 stays true.
ch2, cw2 = min(ih, max(1, int(round(nh / sc)))), min(iw, max(1, int(round(nw / sc))))
y0, x0 = (ih - ch2) // 2, (iw - cw2) // 2
img = img[..., y0:y0 + ch2, x0:x0 + cw2]
img = F.interpolate(img.float(), size=(nh, nw), mode="bicubic", antialias=True)
lat = vae.encode(img.movedim(1, -1)[..., :3].clamp(0, 1))
cache[key] = lat
return lat
# crop (default / "v1 legacy"): center-crop to the target AR, then resize.
s = max(px_h / ih, px_w / iw)
ch, cw = min(ih, int(round(px_h / s))), min(iw, int(round(px_w / s)))
y0, x0 = (ih - ch) // 2, (iw - cw) // 2
img = img[..., y0:y0 + ch, x0:x0 + cw]
img = F.interpolate(img.float(), size=(px_h, px_w), mode="bicubic", antialias=True)
lat = vae.encode(img.movedim(1, -1)[..., :3].clamp(0, 1))
cache[key] = lat
return lat
+103
View File
@@ -0,0 +1,103 @@
"""Full-image Identity Edit passes composed with native ComfyUI spatial masks."""
import torch
from collections import Counter
from .document import region_used_for
from .mask_renderer import render_selection
from .krea2_identity_edit import edit_prompts, grounded_encode
MAX_EDIT_PASSES = 64
def merge_edit_stacks(current, overlay, baseline):
"""Add overlay-local occurrences; preserve order and explicit duplicates.
Common inherited entries are removed occurrence-by-occurrence. Identical
assignments across scopes use the maximum occurrence count, not their sum.
"""
current = [tuple(item) for item in current]
inherited = Counter(tuple(item) for item in baseline)
local = []
for item in map(tuple, overlay):
if inherited[item]:
inherited[item] -= 1
else:
local.append(item)
existing = Counter(current)
required = Counter(tuple(item) for item in baseline)
result = list(current)
for item in local:
required[item] += 1
if required[item] > existing[item]:
result.append(item)
existing[item] += 1
return result
def compose_edit_overlays(passes, overlays, stacks):
baseline = stacks.get("global", [])
composed = [(scope, text, mask, stacks.get(scope, baseline)) for scope, text, mask in passes]
for overlay_scope, alpha in overlays:
combined = {}
def add(scope, text, mask, entries):
if not torch.any(mask > 0):
return
key = (text, tuple(map(tuple, entries)))
if key in combined:
previous = combined[key]
combined[key] = (previous[0], text, previous[2] + mask, entries)
else:
combined[key] = (scope, text, mask, entries)
for scope, text, mask, entries in composed:
add(scope, text, mask * (1. - alpha), entries)
add(scope, text, mask * alpha, merge_edit_stacks(entries, stacks[overlay_scope], baseline))
composed = list(combined.values())
if len(composed) > MAX_EDIT_PASSES:
raise ValueError("Identity Edit LoRA overlaps exceed 64 prompt/stack combinations; reduce overlapping LoRA regions")
return composed
def compile_edit_regions(document, clip, image, *, scope_encoder=None, lora_scopes=None):
encode = scope_encoder or (lambda scope, text: grounded_encode(clip, *text, image))
stacks = lora_scopes or {}
baseline_stack = stacks.get("global", [])
global_text = edit_prompts(document)
width, height = document["canvas"]["width"], document["canvas"]["height"]
regions = []
overlays = []
occupied = torch.zeros((1, 1, height, width), dtype=torch.float32)
for region in document["regions"]:
if not region_used_for(region, "generation") or region["strength"] <= 0:
continue
text = edit_prompts(document, "region", region["id"])
if not any(part.strip() for part in text) and stacks.get(region["id"], baseline_stack) == baseline_stack:
continue
mask = render_selection({"document": document, "scope": "region", "region_id": region["id"]}, width, height).detach().float().cpu()
mask = mask.reshape(1, 1, height, width).clamp(0., 1.) * float(region["strength"])
if not torch.any(mask > 0):
continue
if not any(part.strip() for part in text):
overlays.append((region["id"], mask.clamp(max=1.)))
continue
occupied = torch.maximum(occupied, mask.clamp(max=1.))
combined = tuple("\n".join(part for part in pair if part.strip()) for pair in zip(global_text, text))
regions.append((region["id"], combined, mask))
# Preserve the established global-only conditioning exactly.
if not regions and not overlays:
return encode("global", global_text)
baseline = 1. - occupied
passes = ([("global", global_text, baseline)] if torch.any(baseline > 0) else []) + regions
if overlays:
composed = compose_edit_overlays(passes, overlays, stacks)
else:
composed = [(scope, text, mask, None) for scope, text, mask in passes]
positive, negative = [], []
for scope, text, mask, entries in composed:
pos, neg = encode(scope, text, entries=entries) if overlays and scope_encoder else encode(scope, text)
for source, target in ((pos, positive), (neg, negative)):
for embedding, metadata in source:
values = dict(metadata)
values.pop("area", None)
values.update(mask=mask, mask_strength=1., set_area_to_bounds=False)
target.append([embedding, values])
return positive, negative
+97
View File
@@ -0,0 +1,97 @@
"""Hybrid Krea generation: shared weights, token deltas and spatial static passes."""
import copy
import logging
import uuid
import torch
from .document import region_used_for
from .krea2_attention import compile_krea2_attention, apply_krea2_attention_patch, Krea2AttentionPatch
from .krea2_token_lora import apply_krea2_token_lora_patch, WRAPPER_KEY as TOKEN_WRAPPER
from .krea2_lora_routing import plan_krea2_lora_routes, residual_token_inputs
from .lora_hooks import apply_static_lora_stack, create_hook_groups, apply_attention_hook_passes, _conditionings_with_pass
from .mask_renderer import render_selection
from .static_lora_passes import StaticLoRAPassRouter, prepare_static_models
WRAPPER_KEY = "bv_krea2_generation_lora_passes"
PASS_KEY = "bv_krea2_generation_lora_pass"
OWNER_KEY = "bv_krea2_generation_lora_owner"
def _validate_options(options):
patches = options.get("patches", {})
if any(key != "attn1_patch" or any(not isinstance(patch, Krea2AttentionPatch) for patch in values) for key, values in patches.items()):
raise ValueError("Krea hybrid LoRAs require the native BV Krea attention layout without foreign attention patches")
if options.get("patches_replace"):
raise ValueError("Krea hybrid LoRAs do not support replacement attention patches")
for kind, entries in options.get("wrappers", {}).items():
for key, value in entries.items():
if value and not (len(value) == 1 and ((kind == "diffusion_model" and key == TOKEN_WRAPPER) or (kind == "calc_cond_batch" and key == WRAPPER_KEY))):
raise ValueError("Krea hybrid LoRAs cannot be combined with foreign sampler/model wrappers")
def compile_krea2_generation_loras(model, clip, document, scope_stacks, attention_strength, start_percent, end_percent):
width, height = document["canvas"]["width"], document["canvas"]["height"]
occupied = torch.zeros((1, height, width), dtype=torch.float32)
active_regions = []
for region in document["regions"]:
if not region_used_for(region, "generation"):
continue
mask = render_selection({"document": document, "scope": "region", "region_id": region["id"]}, width, height).detach().float().clamp(0., 1.)
if bool(torch.any(mask > 0)):
active_regions.append(region)
occupied = torch.maximum(occupied, mask)
background_mask = 1. - occupied
effective = dict(scope_stacks)
effective.setdefault("background", effective.get("global", []))
active_scopes = ["global", *[region["id"] for region in active_regions]]
if bool(torch.any(background_mask > 0)):
active_scopes.append("background")
plan = plan_krea2_lora_routes(model, effective, active_scopes)
common_model, _ = apply_static_lora_stack(model, None, plan.common)
# CLIP sees complete effective stacks once, independently of MODEL routing.
clip_scopes = {scope: [(path, 0., cs) for path, _, cs in entries if cs != 0] for scope, entries in effective.items()}
hooks = create_hook_groups(clip_scopes)
positive, negative, slots, aspect_ratio = compile_krea2_attention(document, clip, hooks)
residual = dict(plan.multipass_scopes)
# The uncovered image area follows an explicit Background stack if provided.
residual["global"] = residual.get("background", residual.get("global", []))
keys, models, indices = {}, [], {}
for scope in ["global", *[region["id"] for region in active_regions]]:
entries = residual.get(scope, [])
key = tuple(entries)
if key not in keys:
variant, _ = apply_static_lora_stack(common_model, None, entries)
keys[key] = len(models)
models.append(variant)
indices[scope] = keys[key]
models = prepare_static_models(models)
patched = apply_krea2_attention_patch(models[0], slots, aspect_ratio, attention_strength, start_percent, end_percent)
token_slots, token_scopes = residual_token_inputs(slots, plan.token_scopes, background_mask)
if any(token_scopes.values()):
patched = apply_krea2_token_lora_patch(patched, token_slots, aspect_ratio, document, token_scopes, loaded_states=plan.states)
logging.info("BV Krea generation LoRA routing: shared-static=%s; token-files=%s; spatial-static-variants=%d; residual-routes=%s",
[(path, strength) for path, strength, _ in plan.common],
sorted({entry[0] for entries in plan.token_scopes.values() for entry in entries}), len(models), plan.reasons)
if len(models) == 1:
return patched, positive, negative
# Every variant uses the exact same token modules and attention runtime.
# Only native weight patches differ. The delegate above shares one base model.
for index in range(1, len(models)):
for path, value in patched.object_patches.items():
models[index].add_object_patch(path, value)
models[index].model_options = copy.deepcopy(patched.model_options)
models[0] = patched
owner = str(uuid.uuid4())
def tagged(conditioning, index, mask=None):
return [[embedding, {**metadata, PASS_KEY: index, OWNER_KEY: owner}]
for embedding, metadata in _conditionings_with_pass(conditioning, None, mask)]
active_document = {**document, "regions": active_regions}
positive, negative = apply_attention_hook_passes(positive, negative, active_document, residual, indices, pass_builder=tagged)
import comfy.patcher_extension
result = patched.clone()
router = StaticLoRAPassRouter(tuple(models), owner, pass_key=PASS_KEY, owner_key=OWNER_KEY, validate_options=_validate_options, label="Krea generation")
comfy.patcher_extension.add_wrapper_with_key(comfy.patcher_extension.WrappersMP.CALC_COND_BATCH,
WRAPPER_KEY, router, result.model_options, is_model_options=True)
return result, positive, negative
+184
View File
@@ -0,0 +1,184 @@
"""Global single-image Identity Edit, separate from regional token routing.
Sequence/grounding follow conradlocke's Identity Edit training contract and
lbouaraba/comfyui-krea2edit (Apache-2.0); see docs/design/krea2-identity-edit.md.
"""
import copy
import math
import torch
import torch.nn.functional as F
from .clip_hooks import clip_with_hooks
from .document import selection_prompts
from .dense_attention import check_dense_mask_budget
from .krea2_attention import CONDITIONING_WIDTH, _require_krea2
from .krea2_edit_geometry import _fit_encode_image
WRAPPER_KEY = "bv_krea2_identity_edit"
TEMPLATE = (
"<|im_start|>system\nDescribe the image by detailing the color, shape, size, "
"texture, quantity, text, spatial relationships of the objects and background:"
"<|im_end|>\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
"{}<|im_end|>\n<|im_start|>assistant\n"
)
def require_compatible_patches(options, *, installed=False):
from .edit_lora_passes import WRAPPER_KEY as lora_wrapper
wrappers = options.get("wrappers", {})
foreign = any(value for kind, entries in wrappers.items() for key, value in entries.items()
if not (installed and len(value) == 1 and ((kind == "diffusion_model" and key == WRAPPER_KEY) or (kind == "calc_cond_batch" and key == lora_wrapper))))
if options.get("patches") or options.get("patches_replace") or foreign:
raise ValueError("Identity Edit cannot be combined with another attention/edit/token patch; weight LoRAs and merged checkpoints are supported")
def edit_prompts(document, scope="global", region_id=None):
background = document["prompts"]["background"]
if background["positive_source"].strip() or background["negative_source"].strip():
raise ValueError("Identity Edit currently requires an empty Background prompt; use the Global prompt")
if document.get("negative_mode") == "zero_out":
raise ValueError("Identity Edit needs an image-grounded negative; choose Auto or Prompt instead of Zero out")
clean = copy.deepcopy(document)
pair = (next(region["prompts"] for region in clean["regions"] if region["id"] == region_id)
if scope == "region" else clean["prompts"][scope])
selected = document["reference_images"][0]
for polarity in ("positive", "negative"):
source = pair[f"{polarity}_source"].encode("utf-16-le")
for mention in reversed(pair.get("references", {}).get(polarity, [])):
if any(mention[key] != selected[key] for key in ("collector_id", "resource_id")):
raise ValueError("Identity Edit mentions must refer to the selected source image")
source = source[:mention["start"]*2] + "the source image".encode("utf-16-le") + source[mention["end"]*2:]
pair[f"{polarity}_source"] = source.decode("utf-16-le")
pair.pop("references", None)
positive, negative = selection_prompts({"document": clean, "scope": scope, "region_id": region_id})
return positive["text"], negative["text"]
def validate_inputs(image, target_latent, vae, *, model):
if not torch.is_tensor(image) or image.ndim != 4 or image.shape[0] != 1 or image.shape[-1] not in (3, 4) or min(image.shape[1:3]) < 1:
raise ValueError("Identity Edit source must be one RGB/RGBA IMAGE, not an image batch")
target = target_latent.get("samples") if isinstance(target_latent, dict) else None
if target_latent is None:
raise ValueError("Identity Edit requires target_latent: connect the same LATENT that feeds the sampler")
if not torch.is_tensor(target) or target.is_nested or target.ndim not in (4, 5) or min(target.shape) < 1 or (target.ndim == 5 and target.shape[2] != 1):
raise ValueError("Identity Edit target_latent must contain an image LATENT tensor (4D or 5D with one frame)")
# Match the sampler: generic empty latents are adapted to the model before use.
from comfy.sample import fix_empty_latent_channels
target = fix_empty_latent_channels(model, target,
target_latent.get("downscale_ratio_spacial"), target_latent.get("downscale_ratio_temporal"))
if target.ndim not in (4, 5) or target.shape[1] != 16 or min(target.shape) < 1 or (target.ndim == 5 and target.shape[2] != 1):
raise ValueError(f"Identity Edit target_latent must resolve to a 16-channel image LATENT; got {tuple(target.shape)} after sampler normalization")
if vae is None or not callable(getattr(vae, "encode", None)):
raise ValueError("Identity Edit requires the model's compatible VAE")
return tuple(target.shape[-2:])
def grounded_encode(clip, positive, negative, image, hooks=None, grounding_px=768):
encoder = clip_with_hooks(clip, hooks)
pixels = image[..., :3]
h, w = pixels.shape[1:3]
if max(h, w) > grounding_px:
scale = grounding_px / max(h, w)
pixels = F.interpolate(pixels.movedim(-1, 1), size=(max(1, round(h*scale)), max(1, round(w*scale))), mode="area").movedim(1, -1)
results = []
for text in (positive, negative):
encoded = encoder.encode_from_tokens_scheduled(encoder.tokenize(text, images=[pixels], llama_template=TEMPLATE))
if len(encoded) != 1 or encoded[0][0].ndim != 3 or encoded[0][0].shape[-1] != CONDITIONING_WIDTH:
raise ValueError("Identity Edit needs a Krea 2 Qwen3-VL encoder with vision support and one unscheduled prompt")
embedding, metadata = encoded[0]
metadata = dict(metadata)
metadata.pop("hooks", None) # Reattached once by the existing global MODEL hook pass.
attention = metadata.pop("attention_mask", None)
if attention is not None:
if attention.shape != embedding.shape[:2] or embedding.shape[0] != 1:
raise ValueError("Unsupported Identity Edit conditioning attention mask")
embedding = embedding[:, attention[0].bool(), :]
if embedding.shape[0] != 1 or embedding.shape[1] == 0:
raise ValueError("Identity Edit requires one nonempty conditioning sequence")
results.append([[embedding, metadata]])
return tuple(results)
def edit_forward(model, x, timesteps, context, source, options, ref_boost, fit_mode):
from comfy.ldm.flux.layers import timestep_embedding
temporal = x.ndim == 5
if temporal:
if x.shape[2] != 1:
raise ValueError("Identity Edit supports still images only")
x = x[:, :, 0]
batch, _, height, width = x.shape
patch = model.patch
x = F.pad(x, (0, -width % patch, 0, -height % patch), mode="replicate")
source = source.to(x.device, x.dtype).expand(batch, -1, -1, -1)
source = F.pad(source, (0, -source.shape[-1] % patch, 0, -source.shape[-2] % patch), mode="replicate")
th, tw = x.shape[-2]//patch, x.shape[-1]//patch
sh, sw = source.shape[-2]//patch, source.shape[-1]//patch
def tokens(value):
b, c, h, w = value.shape
packed = value.reshape(b,c,h//patch,patch,w//patch,patch).permute(0,2,4,1,3,5).reshape(b,(h//patch)*(w//patch),c*patch*patch)
return model.first(packed)
target_tokens, source_tokens = tokens(x), tokens(source)
t = model.tmlp(timestep_embedding(timesteps, model.tdim).unsqueeze(1).to(target_tokens.dtype))
tvec = model.tproj(t)
text = model.txtmlp(model.txtfusion(model._unpack_context(context), mask=None, transformer_options=options))
nt, ns, nx = text.shape[1], source_tokens.shape[1], target_tokens.shape[1]
combined = torch.cat((text, source_tokens, target_tokens), dim=1)
def positions(frame, h, w, oy=0., ox=0.):
ids = torch.zeros((batch, h, w, 3), device=x.device)
ids[..., 0] = frame
ids[..., 1] = torch.arange(h, device=x.device)[:, None] + oy
ids[..., 2] = torch.arange(w, device=x.device)[None, :] + ox
return ids.reshape(batch, h*w, 3)
offset = (max(0., (th-sh)/2), max(0., (tw-sw)/2)) if fit_mode == "fit" else (0., 0.)
ids = torch.cat((torch.zeros(batch, nt, 3, device=x.device), positions(1, sh, sw, *offset), positions(0, th, tw)), dim=1)
freqs = model.pe_embedder(ids)
bias = None
if ref_boost != 1.:
length = nt+ns+nx
check_dense_mask_budget(length, 1, combined.dtype, label="Krea Identity Edit reference bias")
bias = torch.zeros((1, 1, length, length), device=x.device, dtype=combined.dtype)
bias[:, :, nt+ns:, nt:nt+ns] = math.log(max(ref_boost, 1e-4))
# No regional token hooks: their native text/target/reference layout differs.
for block in model.blocks:
combined = block(combined, tvec, freqs, bias, transformer_options=options)
result = model.last(combined, t)[:, nt+ns:nt+ns+nx]
result = result.reshape(batch,th,tw,model.channels,patch,patch).permute(0,3,1,4,2,5).reshape(batch,model.channels,th*patch,tw*patch)
result = result[:, :, :height, :width]
return result.unsqueeze(2) if temporal else result
def apply_identity_edit(model, image, vae, target_latent, *, fit_mode="fit", ref_boost=1.):
import comfy.model_base
import comfy.patcher_extension
_require_krea2(model, comfy.model_base.Krea2)
shape = validate_inputs(image, target_latent, vae, model=model)
if fit_mode not in ("fit", "crop") or not math.isfinite(ref_boost) or not 0 < ref_boost <= 100:
raise ValueError("Invalid Identity Edit fit mode or reference boost")
options = model.model_options.get("transformer_options", {})
require_compatible_patches(options)
class CheckedVae:
def encode(self, pixels):
latent = vae.encode(pixels)
if not torch.is_tensor(latent) or latent.ndim not in (4, 5) or (latent.ndim == 5 and latent.shape[2] != 1) or latent.shape[0] != 1 or latent.shape[1] != 16 or tuple(latent.shape[-2:]) != (pixels.shape[1]//8, pixels.shape[2]//8):
raise ValueError("Identity Edit requires a 16-channel VAE with 8x spatial compression")
return latent.unsqueeze(2) if latent.ndim == 4 else latent
source = _fit_encode_image(image, CheckedVae(), *shape, {}, (), fit_mode)
if source.ndim != 5 or source.shape[2] != 1 or source.shape[0] != 1 or source.shape[1] != 16:
raise ValueError("Identity Edit VAE must encode a single 16-channel image latent")
# Krea uses Wan21's per-channel statistics with a singleton time dimension.
source = model.model.process_latent_in(source)[:, :, 0]
result = model.clone()
def wrapper(executor, x, timesteps, context, *args, **kwargs):
if tuple(x.shape[-2:]) != shape:
raise ValueError("Identity Edit target_latent and sampler resolution differ; connect the same latent to both")
opts = kwargs.get("transformer_options")
if opts is None:
opts = next((arg for arg in reversed(args) if isinstance(arg, dict)), {})
require_compatible_patches(opts, installed=True)
native_refs = kwargs.get("ref_latents")
if native_refs or (len(args) >= 3 and args[-2] is not None and len(args[-2])):
raise ValueError("Identity Edit cannot be combined with other native reference latents")
return edit_forward(executor.class_obj, x, timesteps, context, source, dict(opts), ref_boost, fit_mode)
comfy.patcher_extension.add_wrapper_with_key(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, WRAPPER_KEY, wrapper, result.model_options.setdefault("transformer_options", {}))
return result
+20
View File
@@ -0,0 +1,20 @@
"""One Krea target-name resolver shared by header evidence and execution."""
PREFIXES = ("diffusion_model.", "transformer.", "model.", "lora_unet_", "lora_transformer_")
DIFFUSERS_PREFIXES = ("transformer_blocks.", "text_fusion.", "time_embed.", "time_mod_proj", "img_in", "txt_in.", "final_layer.")
def canonical_krea2_targets(keys, layers):
stripped = {}
for raw in keys:
key = raw
for prefix in PREFIXES:
if key.startswith(prefix):
key = key[len(prefix):]
break
stripped[raw] = key
aliases = {}
if any(key.startswith(DIFFUSERS_PREFIXES) for key in stripped.values()):
from comfy.utils import krea2_to_diffusers
aliases = krea2_to_diffusers({"layers": layers}, output_prefix="")
return {raw: aliases.get(key + ".weight", key + ".weight").removesuffix(".weight") for raw, key in stripped.items()}
+128
View File
@@ -0,0 +1,128 @@
"""Plan complete Krea LoRA files before choosing spatial application methods."""
from collections import Counter
from dataclasses import dataclass
import math
import re
from .krea2_token_lora import _module_lookup, _normalize_lora_state
DEFAULT_TOKEN_SCOPE = "__bv_krea2_global_residual"
def residual_token_inputs(slots, token_scopes, background_mask):
"""Global residuals affect global text, never implicitly every image token.
Spatial influence comes exclusively from effective region/background scopes.
A zero-token background slot also covers LoRA-only background assignments.
"""
from dataclasses import replace
from .krea2_attention import Krea2RegionalSlot
scopes = {key: list(value) for key, value in token_scopes.items() if key != "global"}
scopes[DEFAULT_TOKEN_SCOPE] = list(token_scopes.get("global", []))
scopes.setdefault("background", list(token_scopes.get("global", [])))
result = [replace(slot, scope=DEFAULT_TOKEN_SCOPE, mask=background_mask * 0) if slot.scope == "global" else slot for slot in slots]
if not any(slot.scope == "background" for slot in result):
result.append(Krea2RegionalSlot("background", background_mask, 1., 0, "background"))
return result, scopes
@dataclass
class Krea2LoRAPlan:
common: tuple
token_scopes: dict
multipass_scopes: dict
clip_scopes: dict
reasons: dict
states: dict
def _classify(model, state):
# Normalization deliberately ignores incomplete pairs; routing must not.
pairs = {}
for key in state:
match = re.match(r"^(.*?)\.(lora_A|lora_down|lora_B|lora_up)\.weight$", str(key))
if match:
pairs.setdefault(match[1], set()).add("down" if match[2] in {"lora_A", "lora_down"} else "up")
if str(key).endswith(".lokr_t2"):
raise ValueError("Krea LoRA routing does not support convolutional LoKr adapters")
if any(parts != {"down", "up"} for parts in pairs.values()):
raise ValueError("Krea LoRA contains an incomplete down/up pair")
diffusion = model.get_model_object("diffusion_model")
normalized = _normalize_lora_state(state, diffusion)
normalized_bases = {adapter["original_key"] for adapter in normalized.values()}
for raw_key in state:
key = str(raw_key)
if key.startswith(("lora_te", "text_encoder", "clip_")):
continue
match = re.match(r"^(.*?)\.(?:lora_A\.weight|lora_B\.weight|lora_down\.weight|lora_up\.weight|alpha|lokr_w1|lokr_w1_a|lokr_w1_b|lokr_w2|lokr_w2_a|lokr_w2_b)$", key)
if not match or match[1] not in normalized_bases:
raise ValueError(f"Krea LoRA contains an unsupported or incomplete adapter field: {key}")
maskable, all_linear = _module_lookup(diffusion)
modules = dict(diffusion.named_modules())
matched, unmaskable = 0, []
for key, adapter in normalized.items():
if not math.isfinite(float(adapter.get("alpha", adapter.get("alpha_scale", 1.)))):
raise ValueError(f"Krea LoRA has a non-finite alpha at {key}")
path = all_linear.get(key) or all_linear.get(key.replace(".", "_"))
if path is None:
# CLIP adapters are handled independently by the native CLIP loader.
if str(adapter["original_key"]).startswith(("lora_te", "text_encoder", "clip_")):
continue
raise ValueError(f"Krea LoRA model layer cannot be resolved: {key}")
shape = tuple(modules[path].weight.shape)
if adapter["kind"] == "lora":
down, up = adapter["down"], adapter["up"]
valid = down.ndim == up.ndim == 2 and down.shape[0] > 0 and up.shape[1] == down.shape[0] and (up.shape[0], down.shape[1]) == shape
else:
w1, w2 = adapter["w1"], adapter["w2"]
valid = (w1.shape[0] * w2.shape[0], w1.shape[1] * w2.shape[1]) == shape
if not valid:
raise ValueError(f"Krea LoRA shape mismatch at {key}")
matched += 1
if key not in maskable and key.replace(".", "_") not in maskable:
unmaskable.append(key)
if not matched:
raise ValueError("Krea LoRA matched no model layers; cannot choose a safe application route")
return "non-spatial layers: " + ", ".join(unmaskable[:3]) if unmaskable else "token-compatible"
def plan_krea2_lora_routes(model, scope_stacks, active_scopes, *, load_state=None):
scopes = list(dict.fromkeys(active_scopes))
if not scopes:
scopes = ["global"]
entries = {scope: [(str(path), float(ms), float(cs)) for path, ms, cs in scope_stacks.get(scope, [])] for scope in scopes}
if any(not math.isfinite(ms) or not math.isfinite(cs) for stack in entries.values() for _, ms, cs in stack):
raise ValueError("LoRA strengths must be finite")
counts = [Counter((path, ms) for path, ms, _ in stack if ms != 0) for stack in entries.values()]
shared = counts[0].copy()
for count in counts[1:]:
shared &= count
common = []
remaining = shared.copy()
for path, ms, _ in entries.get("global", []):
if ms != 0 and remaining[(path, ms)] > 0:
common.append((path, ms, 0.))
remaining[(path, ms)] -= 1
common_counts = Counter((path, ms) for path, ms, _ in common)
plan = Krea2LoRAPlan(tuple(common), {}, {}, {}, {}, {})
for scope, stack in entries.items():
remove = common_counts.copy()
plan.token_scopes[scope], plan.multipass_scopes[scope] = [], []
plan.clip_scopes[scope] = [(path, 0., cs) for path, _, cs in stack if cs != 0]
for path, ms, _ in stack:
if ms == 0:
continue
if remove[(path, ms)] > 0:
remove[(path, ms)] -= 1
continue
if path not in plan.states:
if load_state is None:
import comfy.utils
state = comfy.utils.load_torch_file(path, safe_load=True)
else:
state = load_state(path)
plan.states[path] = state
plan.reasons[path] = _classify(model, state)
target = plan.token_scopes if plan.reasons[path] == "token-compatible" else plan.multipass_scopes
target[scope].append((path, ms, 0.))
return plan
+19 -3
View File
@@ -81,7 +81,7 @@ def build_token_lora_specs(scope_stacks: dict[str, Any]) -> list[TokenLoRASpec]:
]
def _normalize_lora_state(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _normalize_lora_state(state: dict[str, Any], diffusion_model=None) -> dict[str, dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
for raw_key, value in state.items():
key = str(raw_key)
@@ -140,6 +140,20 @@ def _normalize_lora_state(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
break
adapter["original_key"] = raw_key
normalized[key] = adapter
if diffusion_model is not None:
# Use Comfy's complete official naming contract for Diffusers exports.
# The same resolver is shared by routing and actual token injection.
from .krea2_lora_names import canonical_krea2_targets
layers = max((int(name.split(".")[1]) + 1 for name, _ in diffusion_model.named_modules()
if re.match(r"^blocks\.\d+(?:\.|$)", name)), default=0)
names = canonical_krea2_targets([adapter["original_key"] for adapter in normalized.values()], layers)
resolved = {}
for adapter in normalized.values():
mapped = names[adapter["original_key"]]
if mapped in resolved:
raise ValueError(f"Krea LoRA contains duplicate native/Diffusers target: {mapped}")
resolved[mapped] = adapter
normalized = resolved
return normalized
@@ -276,8 +290,8 @@ def _inject_adapter(
spec: TokenLoRASpec,
excluded_fragments: tuple[str, ...] = (),
) -> tuple[int, list[str], list[str]]:
normalized = _normalize_lora_state(state)
diffusion_model = model.get_model_object("diffusion_model")
normalized = _normalize_lora_state(state, diffusion_model)
maskable, all_linear = _module_lookup(diffusion_model, excluded_fragments)
patched_count = 0
unmatched: list[str] = []
@@ -438,6 +452,8 @@ def apply_krea2_token_lora_patch(
aspect_ratio: float,
document: dict[str, Any],
scope_stacks: dict[str, Any],
*,
loaded_states: dict[str, Any] | None = None,
) -> Any:
specs = build_token_lora_specs(scope_stacks)
if not specs:
@@ -450,7 +466,7 @@ def apply_krea2_token_lora_patch(
result = model.clone()
report = TokenLoRAReport(adapters=len(specs))
loaded: dict[str, dict[str, Any]] = {}
loaded: dict[str, dict[str, Any]] = dict(loaded_states or {})
for spec in specs:
state = loaded.get(spec.path)
if state is None:
+45 -6
View File
@@ -102,7 +102,22 @@ def parse_registry(value: Any) -> dict[str, Any]:
if folded in names:
raise ValueError(f"Duplicate LoRA stack name: {name}")
names.add(folded)
stacks[key] = {"id": identifier, "name": name, "stack": _normalize_stack(item.get("stack"), f"LoRA stack {name}")}
if item.get("role", "normal") not in ("normal", "basis", "global"):
raise ValueError("LoRA stack role must be normal, basis or global")
stacks[key] = {"id": identifier, "name": name, "stack": _normalize_stack(item.get("stack"), f"LoRA stack {name}"),
**({"role": item["role"]} if item.get("role") in ("basis", "global") else {})}
if "global_enabled" in item:
if not isinstance(item["global_enabled"], bool):
raise ValueError("LoRA resource global_enabled must be boolean")
stacks[key]["global_enabled"] = item["global_enabled"]
if "origin_provider_id" in item:
from uuid import UUID
origin = str(UUID(item["origin_provider_id"]))
ids = item.get("entry_ids")
if not isinstance(ids, list) or len(ids) != len(stacks[key]["stack"]) or len(set(ids)) != len(ids):
raise ValueError("LoRA resource entry identities must match its occurrences")
ids = [str(UUID(entry_id)) for entry_id in ids]
stacks[key].update(origin_provider_id=origin, entry_ids=ids)
return {"schema": "bv.lora_stack_registry", "version": 1, "stacks": stacks}
@@ -137,6 +152,8 @@ def resolve_scope_stacks(registry: Any, bindings: Any, document: dict[str, Any])
return {}
stacks = parse_registry(registry)["stacks"]
requested = {stack_id for stack_id in active_assignments if stack_id}
if any(stacks.get(stack_id, {}).get("role") == "basis" for stack_id in requested):
raise ValueError("Basis LoRA groups are applied automatically by the active Global Registry; remove their manual global/regional assignments")
missing = requested.difference(stacks)
if missing:
raise ValueError(f"Assigned LoRA stack is missing: {', '.join(sorted(missing))}")
@@ -209,6 +226,25 @@ def resolve_stack_paths(
return resolved_stacks
def apply_static_lora_stack(model: Any, clip: Any, resolved_entries: Any) -> tuple[Any, Any]:
"""Apply an already resolved, constant stack through native MODEL/CLIP patches.
For consumers with one global stack, no conditional weight switching is needed.
Native loading returns clones; an empty stack preserves the supplied objects.
"""
if not resolved_entries:
return model, clip
import comfy.sd
import comfy.utils
for path, model_strength, clip_strength in resolved_entries:
if model_strength == 0 and clip_strength == 0:
continue
weights = comfy.utils.load_torch_file(path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(model, clip, weights, model_strength, clip_strength)
return model, clip
def create_hook_groups(scope_stacks: dict[str, Any]) -> dict[str, Any]:
if not scope_stacks:
return {}
@@ -268,10 +304,13 @@ def apply_attention_hook_passes(
document: dict[str, Any],
scope_stacks: dict[str, Any],
hook_groups: dict[str, Any],
*,
pass_builder=None,
) -> tuple[list, list]:
"""Split full attention conditioning into masked LoRA model passes."""
if not scope_stacks:
return positive, negative
build_pass = pass_builder or _conditionings_with_pass
import torch
@@ -296,7 +335,7 @@ def apply_attention_hook_passes(
if not grouped_masks:
hooks = hook_groups.get("global")
return _conditionings_with_pass(positive, hooks), _conditionings_with_pass(negative, hooks)
return build_pass(positive, hooks), build_pass(negative, hooks)
override_union = torch.zeros((1, height, width), dtype=torch.float32)
for mask in grouped_masks.values():
@@ -307,10 +346,10 @@ def apply_attention_hook_passes(
negative_passes: list = []
if bool(torch.any(baseline_mask > 0)):
baseline_hooks = hook_groups.get("global")
positive_passes.extend(_conditionings_with_pass(positive, baseline_hooks, baseline_mask))
negative_passes.extend(_conditionings_with_pass(negative, baseline_hooks, baseline_mask))
positive_passes.extend(build_pass(positive, baseline_hooks, baseline_mask))
negative_passes.extend(build_pass(negative, baseline_hooks, baseline_mask))
for key, mask in grouped_masks.items():
hooks = hook_groups.get(representative_scope[key])
positive_passes.extend(_conditionings_with_pass(positive, hooks, mask))
negative_passes.extend(_conditionings_with_pass(negative, hooks, mask))
positive_passes.extend(build_pass(positive, hooks, mask))
negative_passes.extend(build_pass(negative, hooks, mask))
return positive_passes, negative_passes
+98 -5
View File
@@ -49,6 +49,8 @@ def validate_lora_resource_reference(reference: dict[str, Any]) -> None:
def validate_lora_capability(payload: dict[str, Any]) -> None:
version = payload.get("version")
expected = {"version", "collector_id", "entries"} if version == 1 else ({"version", "entries"} if version == 2 else {"version", "entries", "scopes"})
if version == 3 and "automatic" in payload:
expected = expected | {"automatic"}
if version not in {1, 2, 3} or set(payload) != expected:
raise RegionalContextError("LoRA capability has unknown or missing fields")
collector_id = payload.get("collector_id")
@@ -103,6 +105,8 @@ def validate_lora_capability(payload: dict[str, Any]) -> None:
if version == 1 and needs_collector and collector_id is None:
raise RegionalContextError("external LoRA entries require collector_id")
if version == 3:
if "automatic" in payload:
validate_automatic_loras(payload["automatic"])
scopes = payload["scopes"]
if not isinstance(scopes, dict):
raise RegionalContextError("LoRA capability scopes must be an object")
@@ -114,6 +118,26 @@ def validate_lora_capability(payload: dict[str, Any]) -> None:
}})
def validate_automatic_loras(value):
if not isinstance(value, dict) or set(value) not in ({"version", "resources"}, {"version", "resources", "enabled"}) or value["version"] != 1 or not isinstance(value["resources"], list):
raise RegionalContextError("Automatic LoRA provenance requires version 1 resources")
if "enabled" in value and not isinstance(value["enabled"], bool):
raise RegionalContextError("Automatic LoRA enabled must be boolean")
seen = set()
for item in value["resources"]:
if not isinstance(item, dict) or set(item) != {"provider_id", "resource_id", "role", "entry_ids", "stack"}:
raise RegionalContextError("Automatic LoRA resource has unknown or missing fields")
provider = _required_uuid(item["provider_id"], "automatic provider_id")
resource = _required_uuid(item["resource_id"], "automatic resource_id")
identity = (provider, resource)
if identity in seen or item["role"] not in {"basis", "global"}:
raise RegionalContextError("Duplicate or invalid automatic LoRA resource")
seen.add(identity)
parse_registry({"schema": "bv.lora_stack_registry", "version": 1, "stacks": {
resource: {"id": resource, "name": resource, "stack": item["stack"], "origin_provider_id": provider, "entry_ids": item["entry_ids"]}
}})
# BV-LEGACY(marked=2026-08-25, remove-after=2026-10-25): LoRA capability v1 -> v2.
# Remove together with v1 validation/registration after old workflows have migrated.
def migrate_lora_capability_v1(payload: dict[str, Any]) -> dict[str, Any]:
@@ -149,6 +173,7 @@ def normalize_lora_capability(payload: Any) -> dict[str, Any]:
def normalize_lora_prompt_config(payload: Any) -> dict[str, Any]:
"""Convert the shared frontend envelope into the persisted capability payload."""
payload = without_lora_provider_selection(payload)
if isinstance(payload, dict) and payload.get("version") == 3:
if set(payload) != {"version", "entries", "steps"} or payload.get("steps") != []:
raise RegionalContextError("Regional Prompt LoRA config version 3 must contain entries and no steps")
@@ -156,6 +181,32 @@ def normalize_lora_prompt_config(payload: Any) -> dict[str, Any]:
return normalize_lora_capability(payload)
def without_lora_provider_selection(payload, providers=None):
"""Validate explicit provider dependencies; resource state stays in the provider."""
if not isinstance(payload, dict):
return payload
if "apply_global" in payload and not isinstance(payload["apply_global"], bool):
raise RegionalContextError("LoRA apply_global must be boolean")
ids = payload.get("registry_ids", [])
if not isinstance(ids, list) or len(ids) > 20 or any(not isinstance(value, str) for value in ids) or len(set(ids)) != len(ids):
raise RegionalContextError("LoRA registry_ids requires up to 20 unique UUIDs")
for identifier in ids:
if _required_uuid(identifier, "LoRA registry_ids") != str(uuid.UUID(identifier)):
raise RegionalContextError("LoRA registry_ids must be canonical UUIDs")
sources = list(payload.get("entries", []))
for step in payload.get("steps", []):
if isinstance(step, dict):
sources.extend(step.get("entries", []))
dependencies = set(ids)
dependencies.update(entry.get("source", {}).get("collector_id") for entry in sources if isinstance(entry, dict) and isinstance(entry.get("source"), dict) and entry["source"].get("kind") == "external")
dependencies.discard(None)
if len(dependencies) > 20:
raise RegionalContextError("LoRA provider selection exceeds 20 providers")
if providers is not None and any(identifier not in providers for identifier in ids):
raise RegionalContextError("Selected LoRA Registry provider is missing; reconnect its Registry")
return {key: value for key, value in payload.items() if key not in {"registry_ids", "apply_global"}}
def _replace_lora(_current: dict[str, Any], configured: Any) -> dict[str, Any]:
return dict(configured)
@@ -255,7 +306,10 @@ def reidentify_lora_provider(provider: Any, provider_id: str) -> dict[str, Any]:
def transform_lora_capability(value: Any, payload: Any, *, registry: CapabilityRegistry, operation: str = "replace") -> RegionalContext:
context = normalize_context(value, registry=registry)
automatic = context.capabilities.get(LORA_CAPABILITY, {}).get("automatic")
if operation == "clear":
if automatic:
return context.with_capability(LORA_CAPABILITY, {"version": 3, "entries": [], "scopes": {}, "automatic": automatic})
return context.without_capability(LORA_CAPABILITY)
clean = normalize_lora_capability(payload)
clean = {"version": 3, "entries": clean["entries"], "scopes": {}}
@@ -283,6 +337,8 @@ def transform_lora_capability(value: Any, payload: Any, *, registry: CapabilityR
result = handler(current, clean)
result = {"version": 3, "entries": result["entries"], "scopes": {}}
validate_lora_capability(result)
if automatic:
result["automatic"] = automatic
return context.with_capability(LORA_CAPABILITY, result)
@@ -331,9 +387,12 @@ def _transform_lora_scope(
base = _without_lora_target(current_entries, target) if operation == "replace" else current_entries
incoming_ids = {entry["id"] for entry in incoming}
entries = [entry for entry in base if entry["id"] not in incoming_ids] + incoming
if not entries:
automatic = current.get("automatic") if current else None
if not entries and not automatic:
return context.without_capability(LORA_CAPABILITY)
payload = {"version": 3, "entries": entries, "scopes": {}}
if automatic:
payload["automatic"] = automatic
validate_lora_capability(payload)
return context.with_capability(LORA_CAPABILITY, payload)
@@ -348,6 +407,7 @@ def transform_lora_sequence(
"""Apply a transformer config as one or more ordinary LoRA capability operations."""
if not isinstance(config, dict):
raise RegionalContextError("LoRA transformer config must be an object")
config = without_lora_provider_selection(config)
if config.get("version") == 1:
return transform_lora_capability(value, config, registry=registry, operation=fallback_operation)
version = config.get("version")
@@ -429,6 +489,9 @@ def resolve_lora_capability(value: Any, provider: Any = None, *, registry: Capab
raise RegionalContextError(
f"LoRA resource is unresolved: {source['resource_id']!r} in collector {collector_id!r}"
)
if resource.get("role") in {"basis", "global"}:
# Automatic resources cannot be selected a second time through stale pickers.
continue
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"]
@@ -447,13 +510,43 @@ def resolve_lora_capability(value: Any, provider: Any = None, *, registry: Capab
return scopes
def materialize_lora_capability(value: Any, provider: Any = None, *, registry: CapabilityRegistry) -> RegionalContext:
def materialize_lora_capability(value: Any, provider: Any = None, *, registry: CapabilityRegistry, apply_global: bool | None = None) -> RegionalContext:
context = normalize_context(value, registry=registry)
if LORA_CAPABILITY not in context.capabilities:
payload = context.capabilities.get(LORA_CAPABILITY, {"version": 3, "entries": [], "scopes": {}})
enabled = payload.get("automatic", {}).get("enabled", True) if apply_global is None else apply_global
providers = provider if isinstance(provider, dict) and provider.get("schema") != "bv.runtime_resource_provider" else ({provider.get("provider_id"): provider} if isinstance(provider, dict) else {})
automatic = {(item["provider_id"], item["resource_id"]): item for item in payload.get("automatic", {}).get("resources", [])}
incoming = {}
refreshed_origins = set()
for raw in providers.values():
clean = normalize_lora_provider(raw)
refreshed_origins.add(clean["provider_id"])
for resource_id, resource in clean["resources"].items():
origin = resource.get("origin_provider_id", clean["provider_id"])
refreshed_origins.add(origin)
if resource.get("role") not in {"basis", "global"}:
continue
if resource.get("global_enabled", True) is False:
continue
# Old external providers have no entry IDs; stable per-stack ordinals retain occurrences.
ids = resource.get("entry_ids", [str(uuid.uuid5(uuid.UUID(origin), f"{resource_id}:{index}")) for index in range(len(resource["stack"]))])
item = {"provider_id": origin, "resource_id": resource_id, "role": resource["role"], "entry_ids": ids, "stack": [list(entry) for entry in resource["stack"]]}
identity = (origin, resource_id)
if identity in incoming and incoming[identity] != item:
raise RegionalContextError("Conflicting automatic LoRA resource copies")
incoming[identity] = item
automatic = {identity: item for identity, item in automatic.items() if identity[0] not in refreshed_origins}
automatic.update(incoming)
if enabled and len({identity[0] for identity in automatic}) > 1:
raise RegionalContextError("Multiple active Global LoRA Registries in this workflow. Turn the intended Registry Global switch off and on again, or disable the other Global switches.")
if LORA_CAPABILITY not in context.capabilities and not automatic and enabled:
return context
context = context.with_capability(LORA_CAPABILITY, payload)
scopes = resolve_lora_capability(context, provider, registry=registry)
payload = context.require_capability(LORA_CAPABILITY)
return context.with_capability(LORA_CAPABILITY, {"version": 3, "entries": payload["entries"], "scopes": scopes})
result = {"version": 3, "entries": payload["entries"], "scopes": scopes}
if automatic or not enabled:
result["automatic"] = {"version": 1, "resources": list(automatic.values()), **({"enabled": False} if not enabled else {})}
return context.with_capability(LORA_CAPABILITY, result)
def materialized_lora_scopes(value: Any, *, registry: CapabilityRegistry) -> dict[str, list[list[Any]]]:
+36
View File
@@ -0,0 +1,36 @@
"""Serializable reference assignments and execution-local Collector resolution."""
import uuid
def validate_reference_images(value, issues):
if value is None:
return
if not isinstance(value, list) or len(value) > 1:
issues.append("reference_images must contain at most one source image")
return
for item in value:
if not isinstance(item, dict) or set(item) != {"collector_id", "resource_id", "role"} or item.get("role") != "source":
issues.append("reference_images requires collector_id, resource_id and role=source")
continue
for key in ("collector_id", "resource_id"):
try:
if str(uuid.UUID(item[key])) != item[key]:
raise ValueError()
except (ValueError, TypeError, AttributeError):
issues.append(f"reference_images.{key} must be a canonical UUID")
def resolve_source_image(regional, document, providers):
from .reference_registry import REFERENCE_CAPABILITY, resolve_reference
from .context import normalize_context
from .v3_contracts import REGIONAL_V3_CAPABILITY_REGISTRY
images = document.get("reference_images", [])
if document.get("tool_settings", {}).get("references") is False or len(images) != 1:
raise ValueError("Identity Edit requires References enabled and one source image selected in the global References tab")
selected = images[0]
catalog = normalize_context(regional, registry=REGIONAL_V3_CAPABILITY_REGISTRY).require_capability(REFERENCE_CAPABILITY)["entries"]
if not any(all(entry.get(key) == selected[key] for key in ("collector_id", "resource_id")) and entry.get("media_type") == "IMAGE" for entry in catalog):
raise ValueError("Selected source image is missing from the Regional reference catalog")
matches = [value for value in providers.values() if isinstance(value, dict) and value.get("provider_id") == selected["collector_id"]]
if len(matches) != 1:
raise ValueError("Selected reference Collector must be connected exactly once to Krea Attention through Registry routing")
return resolve_reference(matches[0], selected["collector_id"], selected["resource_id"], expected_type="IMAGE")
+42
View File
@@ -0,0 +1,42 @@
"""Stored prompt mentions. Model adapters are deliberately a separate feature."""
import uuid
def validate_prompt_references(pair, path, issues):
if "reference_editor" in pair and not isinstance(pair["reference_editor"], bool):
issues.append(f"{path}.reference_editor must be boolean")
if "references" not in pair:
return
refs = pair["references"]
if not isinstance(refs, dict) or set(refs) - {"positive", "negative"}:
issues.append(f"{path}.references must contain positive/negative lists")
return
for polarity, mentions in refs.items():
if not isinstance(mentions, list) or len(mentions) > 100:
issues.append(f"{path}.references.{polarity} must contain at most 100 mentions")
continue
text = pair.get(f"{polarity}_source", "")
if not isinstance(text, str):
continue
encoded = text.encode("utf-16-le")
previous = 0
for mention in mentions:
try:
if not isinstance(mention, dict) or set(mention) != {"start", "end", "label", "collector_id", "resource_id"}:
raise ValueError()
start, end = mention["start"], mention["end"]
if type(start) is not int or type(end) is not int or start < previous or end <= start or end * 2 > len(encoded):
raise ValueError()
if not isinstance(mention["label"], str) or not mention["label"].startswith("@") or encoded[start * 2:end * 2].decode("utf-16-le") != mention["label"]:
raise ValueError()
for key in ("collector_id", "resource_id"):
if str(uuid.UUID(mention[key])) != mention[key]:
raise ValueError()
previous = end
except (ValueError, TypeError, KeyError, AttributeError, UnicodeError):
issues.append(f"{path}.references.{polarity} contains an invalid or overlapping reference")
def require_reference_application(pair):
if any(pair.get("references", {}).values()):
raise ValueError("This prompt contains saved image/media references. This model path does not support their application. Use a compatible edit mode or remove the reference mentions; disabling @ suggestions does not remove them.")
+179
View File
@@ -0,0 +1,179 @@
"""Serializable reference identities and execution-local native media providers."""
from __future__ import annotations
import json
import re
import uuid
import torch
from .context import CapabilityRegistration, RegionalContextError, normalize_context
REFERENCE_RESOURCE_TYPE = "bv-nodepack.reference"
REFERENCE_CAPABILITY = "bv-nodepack.references"
RUNTIME_PROVIDER = "BV_RUNTIME_RESOURCE_PROVIDER"
MEDIA_TYPES = {"IMAGE": "Image", "AUDIO": "Audio", "VIDEO": "Video"}
MAX_REFERENCES = 100
def _uuid(value, path):
if not isinstance(value, str):
raise RegionalContextError(f"{path} must be a UUID")
try:
parsed = uuid.UUID(value)
except ValueError as error:
raise RegionalContextError(f"{path} must be a UUID") from error
if str(parsed) != value:
raise RegionalContextError(f"{path} must be a canonical UUID")
return value
def parse_reference_config(value):
try:
config = json.loads(value) if isinstance(value, str) else value
except json.JSONDecodeError as error:
raise RegionalContextError("Invalid reference registry JSON") from error
if not isinstance(config, dict) or set(config) not in ({"schema", "version", "collector_id", "entries"}, {"schema", "version", "collector_id", "entries", "places"}) or config.get("schema") != "bv.reference_registry_config" or type(config.get("version")) is not int or config["version"] != 1:
raise RegionalContextError("Expected bv.reference_registry_config v1")
_uuid(config["collector_id"], "collector_id")
if not isinstance(config["entries"], list) or len(config["entries"]) > MAX_REFERENCES:
raise RegionalContextError("Reference entries must be an array of at most 100 entries")
ids, slots = set(), set()
for entry in config["entries"]:
if not isinstance(entry, dict) or set(entry) not in ({"id", "slot"}, {"id", "slot", "media_type"}):
raise RegionalContextError("Reference entry requires id and slot")
if "media_type" in entry and entry["media_type"] not in MEDIA_TYPES:
raise RegionalContextError("Invalid reference place media type")
resource_id = _uuid(entry["id"], "entry.id")
slot = entry["slot"]
if not isinstance(slot, str) or not re.fullmatch(r"media([0-9]|[1-9][0-9])", slot):
raise RegionalContextError("Invalid reference media slot")
if resource_id in ids or slot in slots:
raise RegionalContextError("Duplicate reference identity or slot")
ids.add(resource_id)
slots.add(slot)
if "places" in config:
places = config["places"]
parse_reference_config({key: val for key, val in {**config, "entries": places}.items() if key != "places"})
if any(entry not in places for entry in config["entries"]):
raise RegionalContextError("Active entries must match their logical reference places")
return config
def media_type(value):
if isinstance(value, torch.Tensor) and value.ndim == 4 and value.shape[-1] in (1, 3, 4) and all(value.shape):
return "IMAGE"
if isinstance(value, dict) and isinstance(value.get("waveform"), torch.Tensor):
waveform, rate = value["waveform"], value.get("sample_rate")
if waveform.ndim == 3 and all(waveform.shape) and type(rate) is int and rate > 0:
return "AUDIO"
# The public native VideoInput interface is lazy: never decode for cataloging.
try:
from comfy_api.input import VideoInput
except ImportError:
VideoInput = ()
if isinstance(value, VideoInput):
return "VIDEO"
raise RegionalContextError("Reference must be native IMAGE, AUDIO or VIDEO")
def build_reference_provider(config_json, media):
config = parse_reference_config(config_json)
if not isinstance(media, dict):
raise RegionalContextError("Reference media must be an input map")
entries = {entry["slot"]: entry for entry in config["entries"]}
if set(media) != set(entries):
raise RegionalContextError("Connected media and persisted reference entries differ")
resources, metadata, counts = {}, {}, {}
names = {}
for place in config.get("places", config["entries"]):
kind = media_type(media[place["slot"]]) if place["slot"] in media else place.get("media_type")
if kind:
counts[kind] = counts.get(kind, 0) + 1
names[place["id"]] = f"{MEDIA_TYPES[kind]} {counts[kind]}"
for entry in config["entries"]:
value = media[entry["slot"]]
kind = media_type(value)
resources[entry["id"]] = value
metadata[entry["id"]] = {"name": names[entry["id"]], "media_type": kind}
return {"schema": "bv.runtime_resource_provider", "version": 1,
"provider_id": config["collector_id"], "resource_type": REFERENCE_RESOURCE_TYPE,
"resources": resources, "metadata": metadata}
def reference_catalog(provider):
if not isinstance(provider, dict) or provider.get("schema") != "bv.runtime_resource_provider" or type(provider.get("version")) is not int or provider["version"] != 1 or provider.get("resource_type") != REFERENCE_RESOURCE_TYPE:
raise RegionalContextError("Expected a BV reference resource provider")
collector = _uuid(provider.get("provider_id"), "provider_id")
resources, metadata = provider.get("resources"), provider.get("metadata")
if not isinstance(resources, dict) or not isinstance(metadata, dict) or set(resources) != set(metadata) or len(resources) > MAX_REFERENCES:
raise RegionalContextError("Invalid reference provider resources/metadata")
catalog = []
for resource_id, value in resources.items():
_uuid(resource_id, "resource_id")
info = metadata[resource_id]
if not isinstance(info, dict) or set(info) != {"name", "media_type"} or not isinstance(info["name"], str) or not info["name"].strip() or info["media_type"] != media_type(value):
raise RegionalContextError("Reference metadata does not match its native media")
catalog.append({"collector_id": collector, "resource_id": resource_id, **info})
return {"version": 1, "entries": catalog}
def validate_reference_capability(payload):
if not isinstance(payload, dict) or set(payload) != {"version", "entries"} or type(payload.get("version")) is not int or payload["version"] != 1 or not isinstance(payload["entries"], list):
raise RegionalContextError("Invalid references capability")
seen = set()
for entry in payload["entries"]:
if not isinstance(entry, dict) or set(entry) != {"collector_id", "resource_id", "name", "media_type"}:
raise RegionalContextError("Invalid reference catalog entry")
key = (_uuid(entry["collector_id"], "collector_id"), _uuid(entry["resource_id"], "resource_id"))
if key in seen or entry["media_type"] not in MEDIA_TYPES or not isinstance(entry["name"], str) or not entry["name"].strip():
raise RegionalContextError("Invalid or duplicate reference catalog entry")
seen.add(key)
REFERENCE_REGISTRATION = CapabilityRegistration(version=1, validator=validate_reference_capability,
version_validators={1: validate_reference_capability}, metadata={"display_name": "References"})
def attach_reference_catalog(regional, provider, *, registry):
return normalize_context(regional, registry=registry).with_capability(
REFERENCE_CAPABILITY, reference_catalog(provider)).to_dict()
def materialize_reference_catalog(regional, selection, providers, *, registry):
"""Resolve selected collectors through the existing managed provider channels."""
if isinstance(selection, str):
selection = json.loads(selection)
if not isinstance(selection, dict) or set(selection) != {"version", "collector_ids"} or type(selection["version"]) is not int or selection["version"] != 1 or not isinstance(selection["collector_ids"], list) or len(selection["collector_ids"]) > 20:
raise RegionalContextError("Invalid reference collector selection")
ids = [_uuid(value, "collector_id") for value in selection["collector_ids"]]
if len(set(ids)) != len(ids):
raise RegionalContextError("Duplicate selected reference collector")
catalogs = {}
for name, provider in providers.items():
if not re.fullmatch(r"reference_resource_provider_([1-9]|1\d|20)", name) or provider is None:
continue
catalog = reference_catalog(provider)
collector = provider["provider_id"]
if collector in catalogs:
raise RegionalContextError("Ambiguous connected reference collector")
catalogs[collector] = catalog
if set(catalogs) != set(ids):
raise RegionalContextError("Selected reference collectors do not match connected providers")
if not ids:
return regional
return normalize_context(regional, registry=registry).with_capability(REFERENCE_CAPABILITY,
{"version": 1, "entries": [entry for collector in ids for entry in catalogs[collector]["entries"]]}).to_dict()
def resolve_reference(provider, collector_id, resource_id, *, expected_type=None):
"""Resolve only inside the supplied execution-local provider, without copying media."""
reference_catalog(provider)
if provider["provider_id"] != _uuid(collector_id, "collector_id"):
raise RegionalContextError("Reference collector is not connected")
_uuid(resource_id, "resource_id")
if resource_id not in provider["resources"]:
raise RegionalContextError("Reference resource is missing")
if expected_type is not None and provider["metadata"][resource_id]["media_type"] != expected_type:
raise RegionalContextError("Reference media type mismatch")
return provider["resources"][resource_id]
-3
View File
@@ -140,9 +140,6 @@ def compile_sdxl_attention(
)
)
if len(slots) == 1:
raise ValueError("SDXL attention routing requires at least one prompted background or region mask")
positive = torch.cat([slot.positive for slot in slots], dim=1)
negative = torch.cat([slot.negative for slot in slots], dim=1)
positive_metadata["bv_regional_backend"] = BACKEND_ID
+79
View File
@@ -0,0 +1,79 @@
"""Shared native static-weight switching for spatial conditioning passes."""
import contextvars
import threading
import torch
_LOCK = threading.RLock()
_ACTIVE = contextvars.ContextVar("bv_static_lora_active", default=False)
def prepare_static_models(models):
"""One native non-dynamic delegate shared by every weight variant."""
if len(models) > 1 and getattr(models[0], "is_dynamic", lambda: False)():
delegate = models[0].get_non_dynamic_delegate()
shared = delegate.get_clone_model_override()
return [delegate] + [variant.clone(disable_dynamic=True, model_override=shared) for variant in models[1:]]
return list(models)
class StaticLoRAPassRouter:
def __init__(self, models, owner, *, pass_key, owner_key, validate_options, label="Regional LoRAs"):
self.pass_key, self.owner_key = pass_key, owner_key
self.validate_options, self.label = validate_options, label
self.models = models
self.owner = owner
self.patch_id = models[0].patches_uuid
self.object_patches = {key: id(value) for key, value in models[0].object_patches.items()}
def __call__(self, executor, model, conds, x, timestep, model_options):
import comfy.model_management
from comfy.samplers import get_area_and_mult
if _ACTIVE.get():
raise ValueError(f"{self.label} LoRAs do not support recursive sampler calls")
original = model.current_patcher
if original.patches_uuid != self.patch_id or {key: id(value) for key, value in original.object_patches.items()} != self.object_patches:
raise ValueError(f"Apply additional MODEL LoRAs and model patches before the {self.label} Attention node, or select them in the Regional Editor")
if model_options.get("multigpu_clones") or model_options.get("context_handler") or model_options.get("model_function_wrapper"):
raise ValueError(f"{self.label} LoRAs currently require single-device, full-image sampling")
self.validate_options(model_options.get("transformer_options", {}))
groups = {}
for branch_index, branch in enumerate(conds):
for cond in branch or []:
if any(cond.get(key) is not None for key in ("default", "area", "control", "hooks", "gligen", "additional_models")):
raise ValueError(f"{self.label} LoRAs require full-image BV conditioning without ControlNet, default areas or weight hooks")
index = cond.get(self.pass_key)
if cond.get(self.owner_key) != self.owner or type(index) is not int or not 0 <= index < len(self.models):
raise ValueError(f"{self.label} LoRAs need the positive and negative outputs from the same Attention node")
group = groups.setdefault(index, [[] if branch is not None else None for branch in conds])
group[branch_index].append(cond)
with _LOCK:
token = _ACTIVE.set(True)
sums = [torch.zeros_like(x, dtype=torch.float32) for _ in conds]
counts = [torch.zeros_like(x[:, :1], dtype=torch.float32) for _ in conds]
def activate(patcher):
memory = model.memory_required(list(x.shape))
comfy.model_management.load_models_gpu([patcher], memory_required=memory)
patcher.pre_run()
try:
for index, group in groups.items():
weights = [torch.zeros_like(x[:, :1], dtype=torch.float32) for _ in conds]
for b, branch in enumerate(group):
for cond in branch or []:
area = get_area_and_mult(cond, x, timestep)
if area is not None:
# Native mask multipliers are identical across channels.
weights[b].add_(area.mult[:, :1].float())
if not any(torch.any(weight != 0) for weight in weights):
continue
activate(self.models[index])
outputs = executor(model, group, x, timestep, model_options)
for b, output in enumerate(outputs):
sums[b].add_(output.float() * weights[b])
counts[b].add_(weights[b])
return [(total / count.clamp_min(1e-37)).to(x.dtype) for total, count in zip(sums, counts)]
finally:
try:
activate(original)
finally:
_ACTIVE.reset(token)
+42
View File
@@ -0,0 +1,42 @@
"""Execution filtering for persisted optional tool switches; drafts stay intact."""
import copy
def tool_enabled(document, tool, region_id=None):
owner = document if region_id is None else next((r for r in document["regions"] if r["id"] == region_id), {})
return owner.get("tool_settings", {}).get(tool, True)
def filter_tool_config(document, payload, tool):
result = copy.deepcopy(payload)
if tool == "lora":
def allowed(target):
return tool_enabled(document, tool, target.get("region_id") if target.get("scope") == "region" else None)
entries = []
for entry in result.get("entries", []):
entry["targets"] = [target for target in entry.get("targets", []) if allowed(target)]
if entry["targets"]:
entries.append(entry)
result["entries"] = entries
if "steps" in result:
result["steps"] = [step for step in result["steps"] if allowed(step["target"])]
elif tool == "lut":
jobs = []
for job in result.get("jobs", []):
if job.get("scope") == "global":
if tool_enabled(document, tool):
jobs.append(job)
else:
job["region_ids"] = [rid for rid in job["region_ids"] if tool_enabled(document, tool, rid)]
if job["region_ids"]:
jobs.append(job)
result["jobs"] = jobs
return result
def filter_legacy_bindings(document, bindings):
result = copy.deepcopy(bindings)
if not tool_enabled(document, "lora"):
result["global_stack_id"] = None
result["regions"] = {rid: value for rid, value in result.get("regions", {}).items() if tool_enabled(document, "lora", rid)}
return result
+2
View File
@@ -4,10 +4,12 @@ from .context import CapabilityRegistry
from .detailer_v3 import DETAILER_CAPABILITY, DETAILER_CAPABILITY_REGISTRY
from .lora_v3 import LORA_CAPABILITY, LORA_CAPABILITY_REGISTRY
from .lut_v3 import LUT_CAPABILITY, LUT_CAPABILITY_REGISTRY
from .reference_registry import REFERENCE_REGISTRATION
def regional_v3_capability_registry() -> CapabilityRegistry:
registry = CapabilityRegistry()
registry.register("bv-nodepack", "references", REFERENCE_REGISTRATION, source=__name__)
registry.register(
"bv-nodepack", "lora", LORA_CAPABILITY_REGISTRY.get(LORA_CAPABILITY), source=__name__,
)
+3 -3
View File
@@ -6,6 +6,8 @@ from dataclasses import dataclass
from typing import Any
import torch
from .dense_attention import check_dense_mask_budget
import torch.nn.functional as F
from .context import context_document
@@ -97,9 +99,6 @@ def compile_zimage_attention(document: Any, clip: Any) -> tuple[list, list, list
if pos["source"].strip() or neg["source"].strip():
specifications.append((region["name"], mask, max(0.0, float(region["strength"])), pos, neg))
if len(specifications) == 1:
raise ValueError("Z-Image attention routing requires at least one prompted background or region mask")
attention_masks_positive: list[torch.Tensor] = []
attention_masks_negative: list[torch.Tensor] = []
for name, mask, strength, positive_prompt, negative_prompt in specifications:
@@ -154,6 +153,7 @@ def build_joint_attention_bias(
raise RuntimeError(f"Z-Image sequence has {image_tokens} image tokens, expected at least {raw_image_tokens}")
total = text_tokens + image_tokens
check_dense_mask_budget(total, batch, dtype, "Z-Image")
bias = torch.zeros((batch, 1, total, total), device=device, dtype=dtype)
image_rows = slice(text_tokens, text_tokens + raw_image_tokens)
offset = 0
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "bv_nodepack"
version = "1.3.0"
version = "1.4.0"
description = "Visual regional prompting with sequential regional detailer plans, detector-aware Impact integration, regional LoRA hooks, native composition modes and model-specific attention backends, plus Smart Pipes and ComfyUI workflow tools."
authors = [
{name = "BlackVortex", email = "blackvortexx@proton.me"}
+280 -27
View File
@@ -5,51 +5,304 @@
"description": "BV Regional authoring contract with per-region consumer usage.",
"type": "object",
"additionalProperties": false,
"required": ["schema", "version", "document_id", "title", "canvas", "prompts", "negative_mode", "overlap", "regions"],
"required": [
"schema",
"version",
"document_id",
"title",
"canvas",
"prompts",
"negative_mode",
"overlap",
"regions"
],
"properties": {
"schema": { "const": "bv.regional" },
"version": { "const": 2 },
"document_id": { "$ref": "bv_regional_v1.schema.json#/$defs/id" },
"title": { "type": "string", "minLength": 1, "maxLength": 256 },
"canvas": { "$ref": "bv_regional_v1.schema.json#/$defs/canvas" },
"prompts": { "$ref": "bv_regional_v1.schema.json#/$defs/documentPrompts" },
"negative_mode": { "enum": ["auto", "prompt", "zero_out"] },
"overlap": { "$ref": "bv_regional_v1.schema.json#/$defs/overlap" },
"regions": { "type": "array", "items": { "$ref": "#/$defs/region" } }
"schema": {
"const": "bv.regional"
},
"version": {
"const": 2
},
"document_id": {
"$ref": "bv_regional_v1.schema.json#/$defs/id"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"canvas": {
"$ref": "bv_regional_v1.schema.json#/$defs/canvas"
},
"prompts": {
"$ref": "#/$defs/documentPrompts"
},
"negative_mode": {
"enum": [
"auto",
"prompt",
"zero_out"
]
},
"overlap": {
"$ref": "bv_regional_v1.schema.json#/$defs/overlap"
},
"regions": {
"type": "array",
"items": {
"$ref": "#/$defs/region"
}
},
"tool_settings": {
"$ref": "#/$defs/toolSettings"
},
"reference_images": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"collector_id",
"resource_id",
"role"
],
"properties": {
"collector_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
},
"role": {
"const": "source"
}
}
}
}
},
"$defs": {
"region": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "parent_region_id", "enabled", "usage", "strength", "priority", "prompts", "mask", "geometry", "authoring"],
"required": [
"id",
"name",
"parent_region_id",
"enabled",
"usage",
"strength",
"priority",
"prompts",
"mask",
"geometry",
"authoring"
],
"properties": {
"id": { "$ref": "bv_regional_v1.schema.json#/$defs/id" },
"name": { "type": "string", "minLength": 1, "maxLength": 256 },
"id": {
"$ref": "bv_regional_v1.schema.json#/$defs/id"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 256
},
"parent_region_id": {
"oneOf": [
{ "$ref": "bv_regional_v1.schema.json#/$defs/id" },
{ "type": "null" }
{
"$ref": "bv_regional_v1.schema.json#/$defs/id"
},
{
"type": "null"
}
]
},
"enabled": { "type": "boolean" },
"usage": { "enum": ["generation", "detailer", "both"] },
"strength": { "type": "number", "minimum": 0, "maximum": 10 },
"priority": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
"prompts": { "$ref": "bv_regional_v1.schema.json#/$defs/promptPair" },
"mask": { "$ref": "bv_regional_v1.schema.json#/$defs/regionMask" },
"enabled": {
"type": "boolean"
},
"usage": {
"enum": [
"generation",
"detailer",
"both"
]
},
"strength": {
"type": "number",
"minimum": 0,
"maximum": 10
},
"priority": {
"type": "integer",
"minimum": -2147483648,
"maximum": 2147483647
},
"prompts": {
"$ref": "#/$defs/promptPair"
},
"mask": {
"$ref": "bv_regional_v1.schema.json#/$defs/regionMask"
},
"geometry": {
"type": "array",
"items": {
"oneOf": [
{ "$ref": "bv_regional_v1.schema.json#/$defs/rectangle" },
{ "$ref": "bv_regional_v1.schema.json#/$defs/ellipse" },
{ "$ref": "bv_regional_v1.schema.json#/$defs/polygon" },
{ "$ref": "bv_regional_v1.schema.json#/$defs/brushStroke" },
{ "$ref": "bv_regional_v1.schema.json#/$defs/rasterMask" }
{
"$ref": "bv_regional_v1.schema.json#/$defs/rectangle"
},
{
"$ref": "bv_regional_v1.schema.json#/$defs/ellipse"
},
{
"$ref": "bv_regional_v1.schema.json#/$defs/polygon"
},
{
"$ref": "bv_regional_v1.schema.json#/$defs/brushStroke"
},
{
"$ref": "bv_regional_v1.schema.json#/$defs/rasterMask"
}
]
}
},
"authoring": { "$ref": "bv_regional_v1.schema.json#/$defs/regionAuthoring" }
"authoring": {
"$ref": "bv_regional_v1.schema.json#/$defs/regionAuthoring"
},
"tool_settings": {
"$ref": "#/$defs/toolSettings"
}
}
},
"toolSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"lora": {
"type": "boolean"
},
"lut": {
"type": "boolean"
},
"references": {
"type": "boolean"
}
}
},
"promptPair": {
"type": "object",
"additionalProperties": false,
"required": [
"positive_source",
"negative_source"
],
"properties": {
"positive_source": {
"type": "string"
},
"negative_source": {
"type": "string"
},
"reference_editor": {
"type": "boolean"
},
"references": {
"type": "object",
"additionalProperties": false,
"properties": {
"positive": {
"type": "array",
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"start",
"end",
"label",
"collector_id",
"resource_id"
],
"properties": {
"start": {
"type": "integer",
"minimum": 0
},
"end": {
"type": "integer",
"minimum": 1
},
"label": {
"type": "string",
"pattern": "^@"
},
"collector_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
}
}
}
},
"negative": {
"type": "array",
"maxItems": 100,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"start",
"end",
"label",
"collector_id",
"resource_id"
],
"properties": {
"start": {
"type": "integer",
"minimum": 0
},
"end": {
"type": "integer",
"minimum": 1
},
"label": {
"type": "string",
"pattern": "^@"
},
"collector_id": {
"type": "string",
"format": "uuid"
},
"resource_id": {
"type": "string",
"format": "uuid"
}
}
}
}
}
}
}
},
"documentPrompts": {
"type": "object",
"additionalProperties": false,
"required": [
"global",
"background"
],
"properties": {
"global": {
"$ref": "#/$defs/promptPair"
},
"background": {
"$ref": "#/$defs/promptPair"
}
}
}
}
+2 -2
View File
@@ -71,8 +71,8 @@ test("pilot clipboard remaps paired domain and transport identities without touc
const config={version:3,entries,steps:[{entries:[structuredClone(entries[0])]}]};
const receiver={type:"BV Regional LoRA",properties:{bvRegistryDgPilot:true,bvLoraDgSelections:{resource_provider_1:"transport",resource_provider_2:"external"}},widgets_values:["merge",JSON.stringify(config)]};
const original=structuredClone({nodes:[receiver],subgraphs:[{nodes:[registry]}]}),copied=prepareDgClipboard(original),newRegistry=JSON.parse(copied.subgraphs[0].nodes[0].widgets_values[0]),newConfig=JSON.parse(copied.nodes[0].widgets_values[1]);
assert.notEqual(newRegistry.registry_id,registryId);assert.notEqual(newRegistry.stacks[0].id,resourceId);
assert.equal(newConfig.entries[0].source.collector_id,newRegistry.registry_id);assert.equal(newConfig.entries[0].source.resource_id,newRegistry.stacks[0].id);
assert.notEqual(newRegistry.registry_id,registryId);assert.notEqual(newRegistry.stacks.find(stack=>stack.name==="A").id,resourceId);
assert.equal(newConfig.entries[0].source.collector_id,newRegistry.registry_id);assert.equal(newConfig.entries[0].source.resource_id,newRegistry.stacks.find(stack=>stack.name==="A").id);
assert.deepEqual(newConfig.entries.slice(1),entries.slice(1));assert.equal(newConfig.steps[0].entries[0].source.collector_id,newRegistry.registry_id);
assert.equal(copied.nodes[0].properties.bvLoraDgSelections.resource_provider_1,copied.subgraphs[0].nodes[0].properties.bvDgSenderId);
assert.equal(copied.nodes[0].properties.bvLoraDgSelections.resource_provider_2,"external");
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {workflowGlobalLoraSelection,exclusiveGlobalLoraChanges} from '../ui/src/regional/loraGlobalRegistry.ts';
function registry(graph,enabled){
const config={schema:'bv.lora_registry_config',version:2,registry_id:crypto.randomUUID(),stacks:[{id:crypto.randomUUID(),name:'Global',role:'global',enabled,entries:[]}]};
const node={type:'BV LoRA Registry',graph,widgets:[{name:'config_json',value:JSON.stringify(config)}]};
graph._nodes.push(node);return{node,config};
}
test('saved conflicting selections have no load-order winner and discovery never mutates',()=>{
const root={_nodes:[]};root.rootGraph=root;
const a=registry(root,true),b=registry(root,true),before=[a.node.widgets[0].value,b.node.widgets[0].value];
for(const nodes of [[a.node,b.node],[b.node,a.node]]){
root._nodes=nodes;
const result=workflowGlobalLoraSelection(a.node);
assert.equal(result.conflict,true);assert.equal(result.active.length,2);
}
assert.deepEqual([a.node.widgets[0].value,b.node.widgets[0].value],before);
});
test('rising activation plans one disable per other registry across shared nested definition',()=>{
const root={_nodes:[]};root.rootGraph=root;
const shared={_nodes:[],rootGraph:root};root._nodes.push({subgraph:shared},{subgraph:shared});
const a=registry(root,false),b=registry(shared,true),before=b.node.widgets[0].value;
const activated={...a.config,stacks:a.config.stacks.map(s=>({...s,enabled:true}))};
const changes=exclusiveGlobalLoraChanges(a.node,a.config,activated);
assert.equal(changes.length,1);assert.equal(changes[0].node,b.node);
assert.equal(changes[0].config.stacks[0].enabled,false);
assert.equal(b.node.widgets[0].value,before);
assert.deepEqual(exclusiveGlobalLoraChanges(a.node,activated,activated),[]);
assert.deepEqual(exclusiveGlobalLoraChanges(a.node,activated,a.config),[]);
});
test('deactivating one source never mutates the remaining source or selects another',()=>{
const root={_nodes:[]};root.rootGraph=root;
const a=registry(root,true),b=registry(root,false);
const disabled={...a.config,stacks:a.config.stacks.map(s=>({...s,enabled:false}))};
assert.deepEqual(exclusiveGlobalLoraChanges(a.node,a.config,disabled),[]);
assert.equal(JSON.parse(b.node.widgets[0].value).stacks[0].enabled,false);
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {parseLoraV3Config, serializeLoraV3Config, loraV3CollectorIds} from '../ui/src/regional/loraV3Config.ts';
import {prepareDgClipboard} from './fixtures/dgCanaryPrototype.ts';
import '../ui/src/regional/loraRegistryDgAdapter.ts';
test('explicit registry binding survives repeated normalization and manual entry removal',()=>{
const explicit=crypto.randomUUID(),manual=crypto.randomUUID();
let config={version:3,registry_ids:[explicit],entries:[{id:crypto.randomUUID(),source:{kind:'external',collector_id:manual,resource_id:crypto.randomUUID()},targets:[{scope:'global'}]}],steps:[]};
config=parseLoraV3Config(config);
const saved=serializeLoraV3Config(config);
for(let i=0;i<5;i++)config=parseLoraV3Config(serializeLoraV3Config(config));
assert.equal(serializeLoraV3Config(config),saved);
assert.deepEqual(loraV3CollectorIds(config),[explicit,manual]);
assert.deepEqual(loraV3CollectorIds({...config,entries:[]}),[explicit]);
assert.deepEqual(loraV3CollectorIds({...config,registry_ids:[],entries:[]}),[]);
assert.deepEqual(loraV3CollectorIds({...config,registry_ids:[manual]}),[manual]);
});
for(const placement of ['root','nested','shared-definition'])test(`registry-only clipboard remaps copied domain and preserves external IDs (${placement})`,()=>{
const rid=crypto.randomUUID(),external=crypto.randomUUID();
const registry={id:1,type:'BV LoRA Registry',properties:{bvDgSenderId:'transport',bvRegistryDgPilotSource:true},widgets_values:[JSON.stringify({schema:'bv.lora_registry_config',version:1,registry_id:rid,stacks:[]})]};
const config={version:3,entries:[],steps:[],registry_ids:[rid,external]};
const receiver={id:2,type:'BV Regional LoRA',properties:{bvRegistryDgPilot:true,bvLoraDgSelections:{resource_provider_1:'transport'}},widgets_values:['merge',JSON.stringify(config)]};
const original=placement==='root'?{nodes:[registry,receiver]}:{nodes:[receiver,...(placement==='shared-definition'?[{id:10,type:'Subgraph',subgraphId:'shared'},{id:11,type:'Subgraph',subgraphId:'shared'}]:[])],subgraphs:[{id:'shared',nodes:[registry]}]};
const before=structuredClone(original),copied=prepareDgClipboard(original);
const copiedRegistry=placement==='root'?copied.nodes[0]:copied.subgraphs[0].nodes[0];
const copiedReceiver=placement==='root'?copied.nodes[1]:copied.nodes[0];
const nextRegistry=JSON.parse(copiedRegistry.widgets_values[0]),nextConfig=JSON.parse(copiedReceiver.widgets_values[1]);
assert.notEqual(nextRegistry.registry_id,rid);
assert.deepEqual(nextConfig.registry_ids,[nextRegistry.registry_id,external]);
assert.deepEqual(nextConfig.entries,[]);
assert.deepEqual(original,before);
const receiverOnly=prepareDgClipboard({nodes:[receiver]});
assert.deepEqual(JSON.parse(receiverOnly.nodes[0].widgets_values[1]).registry_ids,[rid,external]);
});
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {observeImageDimensions} from '../ui/src/ui/imageDimensions.ts';
test('image dimension reads reject late results after source switch and cleanup',()=>{
const old={},next={},values=[];const cancel=observeImageDimensions('old',value=>values.push(value),()=>old);const late=old.onload;cancel();
observeImageDimensions('new',value=>values.push(value),()=>next);old.naturalWidth=900;old.naturalHeight=800;late();assert.deepEqual(values,[]);
next.naturalWidth=736;next.naturalHeight=920;next.onload();assert.deepEqual(values,[{width:736,height:920}]);next.onerror();assert.equal(values.at(-1),null);
});
test('zero dimension images do not offer a canvas size',()=>{const img={},values=[];observeImageDimensions('broken',v=>values.push(v),()=>img);img.naturalWidth=0;img.naturalHeight=0;img.onload();assert.deepEqual(values,[null]);});
+25
View File
@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import {inputImagePreview} from "../ui/src/regional/inputImagePreview.ts";
function fixture(){
const source={id:1,type:"LoadImage",widgets:[{name:"image",value:"one.png"}],outputs:[{type:"IMAGE",links:[7]}]},target={id:2,inputs:[{name:"canvas_image",type:"IMAGE",link:7}]};
const graph={_nodes:[source,target],links:{7:{origin_id:1,origin_slot:0,target_id:2,target_slot:0,type:"IMAGE"}},getNodeById(id){return this._nodes.find(n=>n.id===id)}};source.graph=target.graph=graph;return {source,target,graph};
}
test("connected loader follows current selection before execution without mutating graph",()=>{
const {source,target,graph}=fixture(),before=JSON.stringify(graph.links);
assert.deepEqual(inputImagePreview(target,"canvas_image",p=>p),{src:"/view?filename=one.png&subfolder=&type=input",previous:false});
source.imgs=[{src:"/old-preview.png"}];source.widgets[0].value="folder/two #.png [temp]";
assert.equal(inputImagePreview(target,"canvas_image",p=>p).src,"/view?filename=two+%23.png&subfolder=folder&type=temp");
target.inputs[0].link=null;assert.equal(inputImagePreview(target,"canvas_image",p=>p),null);assert.equal(JSON.stringify(graph.links),before);
});
test("preview never traverses transformations and rejects ambiguous/muted sources",()=>{
const {source,target}=fixture();source.type="ImageScale";
assert.equal(inputImagePreview(target,"canvas_image",p=>p),null);
source.imgs=[{src:"blob:previous"}];assert.deepEqual(inputImagePreview(target,"canvas_image",p=>p),{src:"blob:previous",previous:true});
source.outputs.push({type:"IMAGE",links:[]});assert.equal(inputImagePreview(target,"canvas_image",p=>p),null);
source.outputs.pop();source.mode=4;assert.equal(inputImagePreview(target,"canvas_image",p=>p),null);
});
test("invalid filenames and linked loader filename inputs fail closed",()=>{
const {source,target}=fixture();for(const value of ["../secret.png","C:\\secret.png","/secret.png",""]){source.widgets[0].value=value;assert.equal(inputImagePreview(target,"canvas_image",p=>p),null)}
source.widgets[0].value="one.png";source.inputs=[{name:"image",link:9}];assert.equal(inputImagePreview(target,"canvas_image",p=>p),null);
});
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {regionalActiveTools} from '../ui/src/regional/regionalToolState.ts';
import {prepareCompositeRegistryClipboard} from '../ui/src/regional/registryDgClipboard.ts';
import {registryChannelFamily,allowedRegistryFamilies} from '../ui/src/regional/registryDgFamilies.ts';
import {sourceRegionalPrompt} from '../ui/src/regional/regionalSourceDocument.ts';
test('global reference switch preserves selection and is never a region tool',()=>{
const source={collector_id:crypto.randomUUID(),resource_id:crypto.randomUUID(),role:'source'};
const document={reference_images:[source],tool_settings:{references:true}};
const tools=(region=null)=>regionalActiveTools(document,region,{entries:[]},{regions:{}},{jobs:[]});
assert.deepEqual(tools(),['references']);
document.tool_settings.references=false;assert.deepEqual(tools(),[]);
assert.equal(document.reference_images[0],source);
assert.deepEqual(tools({id:'r',tool_settings:{references:true}}),[]);
});
test('copying a reference registry and prompt remaps assignment collector but preserves logical slot',()=>{
const collector_id=crypto.randomUUID(),resource_id=crypto.randomUUID();
const registry={type:'BV Reference Registry',widgets_values:[JSON.stringify({schema:'bv.reference_registry_config',version:1,collector_id,entries:[{id:resource_id,slot:'media0'}]})]};
const document={reference_images:[{collector_id,resource_id,role:'source'}],prompts:{},regions:[]};
const prompt={type:'BV Regional Prompt',widgets_values:[JSON.stringify(document)]};
prepareCompositeRegistryClipboard([registry,prompt]);
const next=JSON.parse(prompt.widgets_values[0]).reference_images[0];
assert.equal(next.collector_id,JSON.parse(registry.widgets_values[0]).collector_id);
assert.notEqual(next.collector_id,collector_id);assert.equal(next.resource_id,resource_id);
});
test('Krea consumes reference family through actual Regional ancestry',()=>{
const prompt={id:1,type:'BV Regional Prompt',outputs:[{type:'BV_REGIONAL',links:[3]}]};
const graph={links:{3:{origin_id:1,origin_slot:0,target_id:2,target_slot:0,type:'BV_REGIONAL'}},getNodeById:id=>id===1?prompt:null};
const model={id:2,type:'BV Regional Krea 2 Attention',graph,inputs:[{name:'regional',type:'BV_REGIONAL',link:3}]};
assert.equal(sourceRegionalPrompt(model),prompt);
assert.deepEqual(allowedRegistryFamilies(model),['reference']);
assert.equal(registryChannelFamily(model,'reference_resource_provider_1'),'reference');
assert.equal(registryChannelFamily(model,'resource_provider_1'),null);
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyClassicNodePresentation,removeNodePresentation} from '../ui/src/regional/classicNodePresentation.ts';
import {installNodePresentationLifecycle} from '../ui/src/regional/nodePresentationLifecycle.ts';
import {installProjectedPortInteraction} from '../ui/src/regional/projectedPortInteraction.ts';
// Native slotCalculations uses !slot.pos, not slot.hidden, for vertical ranks.
function fixture(){
const node={pos:[100,200],size:[320,300],properties:{},inputs:['model','clip','regional','lora_registry','lora_bindings','vae','target_latent'].map(name=>({name,link:null})),outputs:['patched_model','positive','negative'].map(name=>({name,links:[]})),widgets:[{name:'mode',computeSize:()=>[0,20]}],
getInputPos(index){const slot=this.inputs[index];if(slot.pos)return[this.pos[0]+slot.pos[0],this.pos[1]+slot.pos[1]];return[this.pos[0]+10,this.pos[1]+(this.inputs.filter(slot=>!slot.pos&&!slot.widget).indexOf(slot)+.7)*20]},
getConnectionPos(input,index){return this.getInputPos(index)},
getInputOnPos(point){return this.inputs.find((slot,index)=>{const p=this.getInputPos(index);return Math.abs(p[0]-point[0])<8&&Math.abs(p[1]-point[1])<8})},
computeSize(){return[320,40+Math.max(this.inputs.filter(s=>!s.pos).length,this.outputs.filter(s=>!s.pos).length)*20+20]},
setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
return node;
}
test('hidden legacy inputs leave no gaps before optional public Krea inputs',()=>{
const node=fixture(),inputs=[...node.inputs];
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
assert.equal(node.getInputPos(5)[1]-node.getInputPos(2)[1],20);
assert.equal(node.getInputPos(6)[1]-node.getInputPos(5)[1],20);
assert.deepEqual(node.inputs,inputs);
assert.equal(node.getInputOnPos(node.getConnectionPos(true,5)),inputs[5]);
assert.equal(node.widgets_start_y,106);
removeNodePresentation(node);
});
test('debug and connected legacy ports restore their rows without changing slot identities',()=>{
const node=fixture(),inputs=[...node.inputs];
for(const [debug,link,delta]of [[false,null,20],[true,null,60],[false,42,40],[false,null,20]]){
inputs[3].link=link;
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:debug});
assert.equal(node.getInputPos(5)[1]-node.getInputPos(2)[1],delta);
assert.equal(node.getInputOnPos(node.getInputPos(5)),inputs[5]);
assert.equal(node.inputs[3].link,link);node.inputs.forEach((slot,i)=>assert.equal(slot,inputs[i]));
}
removeNodePresentation(node);
assert.ok(inputs.every(slot=>!Object.hasOwn(slot,'pos')));
});
test('hidden anchors are not clickable and later DG ownership still applies',()=>{
const node=fixture();
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
const hidden=node.inputs[3],point=[node.pos[0]+hidden.pos[0],node.pos[1]+hidden.pos[1]];
assert.equal(node.getInputOnPos(point),undefined);
const provider={name:'reference_resource_provider_1',link:null};node.inputs.push(provider);
installProjectedPortInteraction(node,slot=>slot===provider);
assert.equal(node.getInputOnPos(node.getInputPos(7)),undefined);
assert.equal(node.getInputOnPos(node.getInputPos(5)),node.inputs[5]);
removeNodePresentation(node);
});
test('native explicit-pos serialization keeps original geometry and input indices',()=>{
class Node{}
installNodePresentationLifecycle(Node,{name:'BV Regional Krea 2 Attention'});
const node=Object.assign(new Node(),fixture()),original=[42,81];node.inputs[3].pos=original;node.inputs[5].link=99;
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
const data={inputs:node.inputs.map(slot=>({name:slot.name,link:slot.link,pos:slot.pos})),outputs:[]};
node.onSerialize(data);
assert.equal(data.inputs[3].pos,original);assert.equal(Object.hasOwn(data.inputs[4],'pos'),false);
assert.equal(data.inputs[5].link,99);assert.deepEqual(data.inputs.map(slot=>slot.name),node.inputs.map(slot=>slot.name));
removeNodePresentation(node);assert.equal(node.inputs[3].pos,original);
});
test('Classic/Nodes2 transitions restore native positions and compact again',()=>{
const previous=globalThis.LiteGraph,node=fixture();
try{
globalThis.LiteGraph={NODE_SLOT_HEIGHT:20,vueNodesMode:false};
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
assert.equal(Object.hasOwn(node.inputs[3],'pos'),true);
globalThis.LiteGraph.vueNodesMode=true;
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
assert.equal(Object.hasOwn(node.inputs[3],'pos'),false);
globalThis.LiteGraph.vueNodesMode=false;
applyClassicNodePresentation(node,'BV Regional Krea 2 Attention',{legacyDebug:false});
assert.equal(node.getInputPos(5)[1]-node.getInputPos(2)[1],20);
}finally{removeNodePresentation(node);globalThis.LiteGraph=previous;}
});
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {createLoraCatalogClient} from '../ui/src/regional/loraCatalogClient.ts';
import {catalogBackendUpdateRequired,loraRoutingPresentation} from '../ui/src/regional/loraCatalogPresentation.ts';
import {safeCivitaiUrl} from '../ui/src/regional/loraRegistryConfig.ts';
const oldItem={name:'synthetic.safetensors',display_name:'Synthetic',tags:[],trigger_words:[]};
const currentItem={...oldItem,civitai_url:null,compatibility:{status:'unknown',family:null,reason:'Adapter targets have not been verified.',target_model:'unknown'}};
test('old payload absence is a backend update state, not an unknown adapter claim',()=>{
assert.equal(catalogBackendUpdateRequired([oldItem]),true);
assert.equal(loraRoutingPresentation(oldItem),null);
assert.equal(catalogBackendUpdateRequired([]),false);
});
test('present unknown evidence and absent Civitai attribution are valid current payload',()=>{
assert.equal(catalogBackendUpdateRequired([currentItem]),false);
const presentation=loraRoutingPresentation(currentItem);
assert.ok(presentation);
assert.equal(presentation.label,'Not checked');
assert.match(presentation.description,/not been verified/i);
assert.doesNotMatch(presentation.description,/backend.*update/i);
assert.equal(safeCivitaiUrl(currentItem.civitai_url),null);
});
test('catalog fetch preserves compatibility and Civitai values including null',async()=>{
const item={...currentItem,civitai_url:'https://civitai.com/models/123?modelVersionId=456',compatibility:{status:'token_candidate',family:'Krea 2',reason:'Candidate',target_model:'unknown'}};
const client=createLoraCatalogClient(async()=>({ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:[item,currentItem]})}));
const result=await client.load({apiURL:path=>path});
assert.deepEqual(result.items,[item,currentItem]);
assert.equal(result.items[1].civitai_url,null);
assert.equal(result.items[0].compatibility.target_model,'unknown');
});
test('frontend Civitai link rejects unsafe or download URLs but accepts proven model URL',()=>{
for(const value of ['javascript:alert(1)','https://civitai.com.evil.test/models/1','https://evil.test@civitai.com/models/1','https://civitai.com/api/download/models/1','https://civitai.com:999/models/1','https://civitai.com/models/1\n'])assert.equal(safeCivitaiUrl(value),null);
assert.equal(safeCivitaiUrl('https://civitai.com/models/123?modelVersionId=456'),'https://civitai.com/models/123?modelVersionId=456');
});
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from '../ui/node_modules/react/index.js';
import {renderToStaticMarkup} from '../ui/node_modules/react-dom/server.node.js';
import {loraCatalogClient} from '../ui/src/regional/loraCatalogClient.ts';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry} from '../ui/src/regional/loraRegistryConfig.ts';
import {getMaturePreviewVisibility,setMaturePreviewVisibility} from '../ui/src/ui/preferences.ts';
test('real registry node preview obeys shared mature preference in both directions',async()=>{
const previousReact=globalThis.React;globalThis.React=React;const previousWindow=globalThis.window;globalThis.window={addEventListener(){},removeEventListener(){},innerWidth:1200,innerHeight:900};
const {LoraRegistryNodeView}=await import('../ui/src/regional/LoraRegistryNodeView.tsx');
const previousFetch=globalThis.fetch,previousMature=getMaturePreviewVisibility();
const item={name:'portrait.safetensors',display_name:'Portrait',base_model:'Krea',tags:[],trigger_words:[],author:'',description:'',size:1,preview_url:'/local-preview.jpg',preview_safe:false,metadata_sources:[],type:'LoRA',category:'',directory:''};
const config=emptyLoraRegistryConfig(),stack=newLoraRegistryStack();stack.entries.push(newLoraRegistryEntry(item.name));config.stacks.push(stack);
try{
globalThis.fetch=async()=>({ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:[item]})});
await loraCatalogClient.reload({apiURL:p=>p});
for(const allowed of [false,true,false]){
setMaturePreviewVisibility(allowed,false);
const markup=renderToStaticMarkup(React.createElement(LoraRegistryNodeView,{stored:JSON.stringify(config),onStored(){},onOpenLibrary(){}}));
const anchor=markup.match(/<span class="bv-hover-preview-anchor"[^>]*>/)?.[0];
assert.ok(anchor,'actual shared HoverPreview rendered');
assert.equal(anchor.includes('tabindex="0"'),allowed,`mature preference ${allowed} controls preview availability`);
}
}finally{globalThis.React=previousReact;globalThis.window=previousWindow;globalThis.fetch=previousFetch;setMaturePreviewVisibility(previousMature,false);loraCatalogClient.invalidate();}
});
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {createLoraRegistryAutosave} from '../ui/src/regional/loraRegistryAutosave.ts';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry,serializeLoraRegistryConfig} from '../ui/src/regional/loraRegistryConfig.ts';
function fixture(){
const initial=emptyLoraRegistryConfig();initial.stacks.push(newLoraRegistryStack('Local'));
let stored=serializeLoraRegistryConfig(initial);const writes=[];
const controller=createLoraRegistryAutosave(stored,value=>{stored=value;writes.push(JSON.parse(value))},()=>stored);
return {controller,writes,get stored(){return stored},external(config){stored=serializeLoraRegistryConfig(config)},initial};
}
const rename=(config,name)=>({...config,stacks:config.stacks.map(s=>s.role==='global'?s:{...s,name})});
test('valid commits persist synchronously and Undo/Redo persist without clearing history',()=>{
const f=fixture();f.controller.commit(c=>rename(c,'One'));assert.equal(JSON.parse(f.stored).stacks[1].name,'One');
f.controller.commit(c=>rename(c,'Two'));assert.equal(f.controller.canUndo,true);
f.controller.undo();assert.equal(JSON.parse(f.stored).stacks[1].name,'One');assert.equal(f.controller.canRedo,true);
f.controller.redo();assert.equal(JSON.parse(f.stored).stacks[1].name,'Two');assert.equal(f.writes.length,4);
});
test('invalid draft does not overwrite stored state and repaired draft persists',()=>{
const f=fixture(),before=f.stored;f.controller.commit(c=>rename(c,''));
assert.equal(f.stored,before);assert.equal(f.writes.length,0);assert.equal(f.controller.value.stacks[1].name,'');
f.controller.commit(c=>rename(c,'Repaired'));assert.equal(JSON.parse(f.stored).stacks[1].name,'Repaired');
});
test('external Global deselection rebases both past and future so Undo never steals Global',()=>{
const f=fixture();f.controller.commit(c=>({...c,stacks:c.stacks.map(s=>s.role==='global'?{...s,enabled:true}:s)}));
f.controller.commit(c=>rename(c,'One'));f.controller.commit(c=>rename(c,'Two'));f.controller.undo();
const peer=JSON.parse(f.stored);peer.stacks[0].enabled=false;f.external(peer);f.controller.sync(f.stored);
f.controller.redo();assert.equal(JSON.parse(f.stored).stacks[0].enabled,false);
f.controller.undo();f.controller.undo();assert.equal(JSON.parse(f.stored).stacks[0].enabled,false);
});
test('invalid local draft and external catalog addition both survive repair',()=>{
const f=fixture();f.controller.commit(c=>rename(c,''));const peer=JSON.parse(f.stored),entry=newLoraRegistryEntry('external.safetensors');peer.stacks[1].entries.push(entry);f.external(peer);
f.controller.commit(c=>rename(c,'Repaired'));
assert.equal(JSON.parse(f.stored).stacks[1].name,'Repaired');assert.deepEqual(JSON.parse(f.stored).stacks[1].entries,[entry]);
f.controller.undo();assert.deepEqual(f.controller.value.stacks[1].entries,[entry]);assert.equal(f.controller.value.stacks[1].name,'');
});
test('stale object commit cannot overwrite externally changed Global flag or added entries',()=>{
const f=fixture(),draft=rename(structuredClone(f.controller.value),'Draft');const peer=JSON.parse(f.stored);peer.stacks[0].enabled=true;peer.stacks[1].entries.push(newLoraRegistryEntry('external.safetensors'));f.external(peer);
f.controller.commit(draft);
assert.equal(JSON.parse(f.stored).stacks[0].enabled,true);assert.equal(JSON.parse(f.stored).stacks[1].entries.length,1);assert.equal(JSON.parse(f.stored).stacks[1].name,'Draft');
});
test('self-notification does not trigger recursive writes',()=>{
const f=fixture();f.controller.commit(c=>rename(c,'One'));for(let i=0;i<5;i++)f.controller.sync(f.stored);assert.equal(f.writes.length,1);
});
test('a failed save remains retryable instead of becoming a falsely saved baseline',()=>{
const initial=emptyLoraRegistryConfig();initial.stacks.push(newLoraRegistryStack('Local'));let attempts=0;
const controller=createLoraRegistryAutosave(initial,()=>{attempts++;if(attempts===1)throw new Error('synthetic write failure')});
assert.throws(()=>controller.commit(c=>rename(c,'Changed')),/synthetic/);
controller.commit(c=>c);assert.equal(attempts,2);
});
+32 -32
View File
@@ -33,19 +33,19 @@ test("LoRA registry config preserves nested stack and entry identities",()=>{
value.stacks.push(portrait,style);
const parsed=parseLoraRegistryConfig(serializeLoraRegistryConfig(value));
assert.equal(parsed.registry_id,value.registry_id);
assert.deepEqual(parsed.stacks.map(stack=>stack.id),[portrait.id,style.id]);
assert.equal(parsed.stacks[1].entries[0].id,style.entries[0].id);
assert.equal(parsed.stacks[1].entries[0].model_strength,.75);
assert.deepEqual(parsed.stacks.filter(stack=>stack.role!=="global").map(stack=>stack.id),[portrait.id,style.id]);
assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[1].entries[0].id,style.entries[0].id);
assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[1].entries[0].model_strength,.75);
});
test("stable entry reorder preserves identity, data and serialization",()=>{
const value=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Order");
const entries=["a","b","c"].map((name,index)=>{const entry=newLoraRegistryEntry(`${name}.safetensors`);entry.enabled=index!==1;entry.model_strength=.2+index;entry.clip_strength=.1+index;return entry});
stack.entries=entries;value.stacks=[stack];
stack.entries=entries;value.stacks.push(stack);
const moved=moveLoraEntry(value,stack.id,entries[2].id,entries[0].id,"before");
const parsed=parseLoraRegistryConfig(serializeLoraRegistryConfig(moved));
assert.deepEqual(parsed.stacks[0].entries.map(entry=>entry.id),[entries[2].id,entries[0].id,entries[1].id]);
assert.deepEqual(parsed.stacks[0].entries.map(({lora_name,enabled,model_strength,clip_strength})=>({lora_name,enabled,model_strength,clip_strength})),[
assert.deepEqual(parsed.stacks.filter(stack=>stack.role!=="global")[0].entries.map(entry=>entry.id),[entries[2].id,entries[0].id,entries[1].id]);
assert.deepEqual(parsed.stacks.filter(stack=>stack.role!=="global")[0].entries.map(({lora_name,enabled,model_strength,clip_strength})=>({lora_name,enabled,model_strength,clip_strength})),[
{lora_name:"c.safetensors",enabled:true,model_strength:2.2,clip_strength:2.1},
{lora_name:"a.safetensors",enabled:true,model_strength:.2,clip_strength:.1},
{lora_name:"b.safetensors",enabled:false,model_strength:1.2,clip_strength:1.1},
@@ -55,29 +55,29 @@ test("stable entry reorder preserves identity, data and serialization",()=>{
test("entry and stack strength deltas preserve mixed values including disabled entries",()=>{
const value=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Mixed"),first=newLoraRegistryEntry("one.safetensors"),second=newLoraRegistryEntry("two.safetensors");
first.model_strength=.8;first.clip_strength=.55;second.enabled=false;second.model_strength=-4.95;second.clip_strength=.1;stack.entries=[first,second];value.stacks=[stack];
first.model_strength=.8;first.clip_strength=.55;second.enabled=false;second.model_strength=-4.95;second.clip_strength=.1;stack.entries=[first,second];value.stacks.push(stack);
const entryChanged=applyEntryStrengthDelta(value,stack.id,first.id,.1);
assert.deepEqual([entryChanged.stacks[0].entries[0].model_strength,entryChanged.stacks[0].entries[0].clip_strength],[.9,.65]);
assert.deepEqual([entryChanged.stacks.filter(stack=>stack.role!=="global")[0].entries[0].model_strength,entryChanged.stacks.filter(stack=>stack.role!=="global")[0].entries[0].clip_strength],[.9,.65]);
const changed=applyStackStrengthDelta(value,stack.id,.1);
assert.deepEqual([changed.stacks[0].entries[0].model_strength,changed.stacks[0].entries[0].clip_strength],[.9,.65]);
assert.deepEqual([changed.stacks[0].entries[1].model_strength,changed.stacks[0].entries[1].clip_strength],[-4.85,.2]);
assert.equal(changed.stacks[0].entries[1].enabled,false);
assert.deepEqual([changed.stacks.filter(stack=>stack.role!=="global")[0].entries[0].model_strength,changed.stacks.filter(stack=>stack.role!=="global")[0].entries[0].clip_strength],[.9,.65]);
assert.deepEqual([changed.stacks.filter(stack=>stack.role!=="global")[0].entries[1].model_strength,changed.stacks.filter(stack=>stack.role!=="global")[0].entries[1].clip_strength],[-4.85,.2]);
assert.equal(changed.stacks.filter(stack=>stack.role!=="global")[0].entries[1].enabled,false);
assert.deepEqual([first.model_strength,first.clip_strength],[.8,.55]);
});
test("LoRA scrubbing applies stable 0.05 increments and cancel restores the complete snapshot",()=>{
const initial=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Scrub"),entry=newLoraRegistryEntry("one.safetensors");stack.entries=[entry];initial.stacks=[stack];
const initial=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Scrub"),entry=newLoraRegistryEntry("one.safetensors");stack.entries=[entry];initial.stacks.push(stack);
const totals=[5,10,15].map(dx=>numberScrubValue(0,dx,.01,-10,10,.05));assert.deepEqual(totals,[.05,.1,.15]);assert.ok(totals.map((value,index)=>value-(totals[index-1]??0)).every(increment=>Math.abs(increment-.05)<1e-12));
let current=initial;const session=createScrubSnapshotSession();session.start(current);current=applyEntryStrengthDelta(current,stack.id,entry.id,.05);current=applyStackStrengthDelta(current,stack.id,.1);assert.notDeepEqual(current,initial);session.cancel(value=>{current=value});assert.deepEqual(current,initial);assert.equal(session.active(),false);
});
test("entry and stack strength deltas preserve relative differences at clamp boundaries",()=>{
const value=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Boundary"),first=newLoraRegistryEntry("one.safetensors"),second=newLoraRegistryEntry("two.safetensors");
first.model_strength=4.9;first.clip_strength=4;second.enabled=false;second.model_strength=-4.8;second.clip_strength=-3.6;stack.entries=[first,second];value.stacks=[stack];
const entryChanged=applyEntryStrengthDelta(value,stack.id,first.id,.5),changedEntry=entryChanged.stacks[0].entries[0];
first.model_strength=4.9;first.clip_strength=4;second.enabled=false;second.model_strength=-4.8;second.clip_strength=-3.6;stack.entries=[first,second];value.stacks.push(stack);
const entryChanged=applyEntryStrengthDelta(value,stack.id,first.id,.5),changedEntry=entryChanged.stacks.filter(stack=>stack.role!=="global")[0].entries[0];
assert.deepEqual([changedEntry.model_strength,changedEntry.clip_strength],[5,4.1]);
assert.ok(Math.abs((changedEntry.model_strength-changedEntry.clip_strength)-.9)<1e-9);
const stackChanged=applyStackStrengthDelta(value,stack.id,-.5),changed=stackChanged.stacks[0].entries;
const stackChanged=applyStackStrengthDelta(value,stack.id,-.5),changed=stackChanged.stacks.filter(stack=>stack.role!=="global")[0].entries;
assert.deepEqual([changed[0].model_strength,changed[0].clip_strength,changed[1].model_strength,changed[1].clip_strength],[4.7,3.8,-5,-3.8]);
assert.ok(Math.abs((changed[0].model_strength-changed[0].clip_strength)-.9)<1e-9);
assert.ok(Math.abs((changed[1].model_strength-changed[1].clip_strength)-(-1.2))<1e-9);
@@ -92,18 +92,18 @@ test("entry and stack switches preserve configuration while producing an empty a
stack.enabled=false;
assert.equal(activeLoraCount(stack),0);assert.equal(loraRegistryIsEmptyActive(value),true);
const parsed=parseLoraRegistryConfig(serializeLoraRegistryConfig(value));
assert.equal(parsed.stacks[0].name,"Compare");assert.equal(parsed.stacks[0].entries.length,2);assert.equal(parsed.stacks[0].entries[1].enabled,true);
assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].name,"Compare");assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].entries.length,2);assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].entries[1].enabled,true);
});
test("clearing stacks preserves the stable registry provider identity",()=>{
const value=emptyLoraRegistryConfig();value.stacks.push(newLoraRegistryStack("Comparison"));
const cleared=clearLoraRegistryStacks(value);
assert.equal(cleared.registry_id,value.registry_id);assert.deepEqual(cleared.stacks,[]);assert.equal(loraRegistryIsEmptyActive(cleared),true);
assert.equal(cleared.registry_id,value.registry_id);assert.equal(cleared.stacks.length,1);assert.equal(cleared.stacks[0].role,"global");assert.deepEqual(cleared.stacks[0].entries,[]);assert.equal(loraRegistryIsEmptyActive(cleared),true);
});
test("frontend validation mirrors backend identity path and strength constraints",()=>{
const value=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Unsafe"),entry=newLoraRegistryEntry("model.safetensors");
stack.entries=[entry];value.stacks=[stack];assert.equal(loraRegistryValidation(value).valid,true);
stack.entries=[entry];value.stacks.push(stack);assert.equal(loraRegistryValidation(value).valid,true);
for(const name of ["../outside.safetensors","/absolute/model.safetensors","C:/models/model.safetensors","model.ckpt","folder//model.safetensors"]){
entry.lora_name=name;assert.equal(loraRegistryValidation(value).invalidPath,true,name);
}
@@ -117,9 +117,9 @@ test("uppercase UUIDs canonicalize once to the same lowercase runtime identities
const registryId=crypto.randomUUID(),stackId=crypto.randomUUID(),entryId=crypto.randomUUID();
const raw={schema:"bv.lora_registry_config",version:1,registry_id:registryId.toUpperCase(),stacks:[{id:stackId.toUpperCase(),name:"Canonical",enabled:true,entries:[{id:entryId.toUpperCase(),lora_name:"model.safetensors",enabled:true,model_strength:1,clip_strength:1}]}]};
const parsed=parseLoraRegistryConfig(raw),strict=strictLoraRegistryConfig(raw);
assert.equal(parsed.registry_id,registryId);assert.equal(parsed.stacks[0].id,stackId);assert.equal(parsed.stacks[0].entries[0].id,entryId);
assert.equal(parsed.registry_id,registryId);assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].id,stackId);assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].entries[0].id,entryId);
assert.deepEqual(strict,parsed);assert.equal(serializeLoraRegistryConfig(parseLoraRegistryConfig(serializeLoraRegistryConfig(parsed))),serializeLoraRegistryConfig(parsed));
const copy=freshenLoraRegistryIdentities(parsed);assert.equal(copy.registry_id,copy.registry_id.toLowerCase());assert.equal(copy.stacks[0].id,copy.stacks[0].id.toLowerCase());
const copy=freshenLoraRegistryIdentities(parsed);assert.equal(copy.registry_id,copy.registry_id.toLowerCase());assert.equal(copy.stacks.filter(stack=>stack.role!=="global")[0].id,copy.stacks.filter(stack=>stack.role!=="global")[0].id.toLowerCase());
});
test("strict config rejects malformed raw structures before normalization",()=>{
@@ -147,7 +147,7 @@ test("duplicate IDs and duplicate names remain visible validation errors",()=>{
{id,name:"Same",enabled:true,entries:[{id:entryId,lora_name:"a.safetensors",enabled:true,model_strength:1,clip_strength:1}]},
{id,name:"same",enabled:true,entries:[{id:entryId,lora_name:"b.safetensors",enabled:true,model_strength:1,clip_strength:1}]},
]});
assert.equal(parsed.stacks[0].id,parsed.stacks[1].id);
assert.equal(parsed.stacks.filter(stack=>stack.role!=="global")[0].id,parsed.stacks.filter(stack=>stack.role!=="global")[1].id);
assert.equal(loraRegistryValidation(parsed).valid,false);
assert.equal(loraRegistryValidation(parsed).duplicateName,"same");
assert.equal(loraRegistryValidation(parsed).duplicateId,true);
@@ -158,8 +158,8 @@ test("copy reconciliation gives the copied registry and all nested entries fresh
assert.equal(needsFreshLoraRegistryId("1",value.registry_id,[{nodeId:"1",registryId:value.registry_id},{nodeId:"2",registryId:value.registry_id}]),false);
assert.equal(needsFreshLoraRegistryId("2",value.registry_id,[{nodeId:"1",registryId:value.registry_id},{nodeId:"2",registryId:value.registry_id}]),true);
const copy=freshenLoraRegistryIdentities(value);
assert.notEqual(copy.registry_id,value.registry_id);assert.notEqual(copy.stacks[0].id,value.stacks[0].id);assert.notEqual(copy.stacks[0].entries[0].id,value.stacks[0].entries[0].id);
assert.equal(copy.stacks[0].name,"Portrait");assert.equal(copy.stacks[0].entries[0].lora_name,"portrait.safetensors");
assert.notEqual(copy.registry_id,value.registry_id);assert.notEqual(copy.stacks.filter(stack=>stack.role!=="global")[0].id,value.stacks.filter(stack=>stack.role!=="global")[0].id);assert.notEqual(copy.stacks.filter(stack=>stack.role!=="global")[0].entries[0].id,value.stacks.filter(stack=>stack.role!=="global")[0].entries[0].id);
assert.equal(copy.stacks.filter(stack=>stack.role!=="global")[0].name,"Portrait");assert.equal(copy.stacks.filter(stack=>stack.role!=="global")[0].entries[0].lora_name,"portrait.safetensors");
});
test("catalog search covers filename, model, author, tags and trigger words",()=>{
@@ -197,9 +197,9 @@ test("library add targets stacks by stable identity and fails closed for stale t
value.stacks.push(first,second);
const added=addLoraEntryToStack(value,second.id,"styles/ink.safetensors");
assert.equal(added.added,true);assert.ok(added.entryId);
assert.equal(added.config.stacks[0].entries.length,0);
assert.equal(added.config.stacks[1].entries.length,1);
assert.equal(added.config.stacks[1].entries[0].lora_name,"styles/ink.safetensors");
assert.equal(added.config.stacks.filter(stack=>stack.role!=="global")[0].entries.length,0);
assert.equal(added.config.stacks.filter(stack=>stack.role!=="global")[1].entries.length,1);
assert.equal(added.config.stacks.filter(stack=>stack.role!=="global")[1].entries[0].lora_name,"styles/ink.safetensors");
assert.notEqual(added.config,value);
const stale=addLoraEntryToStack(value,crypto.randomUUID(),"styles/ink.safetensors");
assert.deepEqual(stale,{config:value,added:false});
@@ -208,14 +208,14 @@ test("library add targets stacks by stable identity and fails closed for stale t
test("library add merges into the latest stored config instead of its open-time snapshot",()=>{
const opened=emptyLoraRegistryConfig(),stack=newLoraRegistryStack("Concurrent"),first=newLoraRegistryEntry("one.safetensors"),second=newLoraRegistryEntry("two.safetensors");
stack.entries.push(first,second);opened.stacks.push(stack);
const latest=structuredClone(opened);latest.stacks[0].enabled=false;latest.stacks[0].entries.reverse();latest.stacks[0].entries[0].model_strength=.42;
const latest=structuredClone(opened);latest.stacks.filter(stack=>stack.role!=="global")[0].enabled=false;latest.stacks.filter(stack=>stack.role!=="global")[0].entries.reverse();latest.stacks.filter(stack=>stack.role!=="global")[0].entries[0].model_strength=.42;
let stored=serializeLoraRegistryConfig(latest);
const merged=addLoraEntryToLatestStored(()=>stored,stack.id,"three.safetensors");
assert.equal(merged.added,true);assert.equal(merged.config.stacks[0].enabled,false);
assert.deepEqual(merged.config.stacks[0].entries.slice(0,2).map(entry=>entry.id),[second.id,first.id]);
assert.equal(merged.config.stacks[0].entries[0].model_strength,.42);
assert.equal(merged.config.stacks[0].entries[2].lora_name,"three.safetensors");
assert.equal(merged.added,true);assert.equal(merged.config.stacks.filter(stack=>stack.role!=="global")[0].enabled,false);
assert.deepEqual(merged.config.stacks.filter(stack=>stack.role!=="global")[0].entries.slice(0,2).map(entry=>entry.id),[second.id,first.id]);
assert.equal(merged.config.stacks.filter(stack=>stack.role!=="global")[0].entries[0].model_strength,.42);
assert.equal(merged.config.stacks.filter(stack=>stack.role!=="global")[0].entries[2].lora_name,"three.safetensors");
stored=serializeLoraRegistryConfig({...latest,stacks:[]});
const stale=addLoraEntryToLatestStored(()=>stored,stack.id,"four.safetensors");
assert.equal(stale.added,false);assert.deepEqual(stale.config.stacks,[]);
assert.equal(stale.added,false);assert.equal(stale.config.stacks.length,1);assert.equal(stale.config.stacks[0].role,"global");
});
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {configureReactNodeWidgetHost,renderReactNodeWidget,removeReactNodeWidgets} from '../ui/src/regional/reactNodeWidgetHost.tsx';
test('central content growth caps automatic height but permits larger saved user height',()=>{
let measure,options;const queue=[],host={className:'',dataset:{},remove(){}},content={className:'',style:{}};
configureReactNodeWidgetHost({createHost:()=>host,createContentHost:()=>content,createRoot:()=>({render(){},unmount(){}}),schedule:action=>queue.push(action),applyPresentation(){},viewportHeight:()=>1000,observeHost:(_host,callback)=>{measure=callback;return()=>{}}});
const node={size:[510,700],properties:{},addDOMWidget(_name,_type,_host,opts){options=opts;return{}}};
renderReactNodeWidget(node,'BV LoRA Registry',{id:'review',name:'review',minHeight:72,maxHeight:340,growWithContent:true,render:()=>null});
measure(250);while(queue.length)queue.shift()();assert.equal(options.getMinHeight(),250);
measure(1200);while(queue.length)queue.shift()();assert.equal(options.getMinHeight(),340);assert.equal(options.getMaxHeight(),340);
node.properties={bvPresentationSizeVersion:1,bvPresentationUserHeight:700};
assert.equal(options.getMinHeight(),72);assert.equal(options.getMaxHeight(),Infinity);
assert.equal(content.style.maxHeight,'100%');assert.deepEqual(node.size,[510,700]);
removeReactNodeWidgets(node);
});
test('automatic content height also obeys the shared viewport cap',()=>{
let measure,options;const host={className:'',dataset:{},remove(){}},content={className:'',style:{}};
configureReactNodeWidgetHost({createHost:()=>host,createContentHost:()=>content,createRoot:()=>({render(){},unmount(){}}),schedule:action=>action(),applyPresentation(){},viewportHeight:()=>400,observeHost:(_host,callback)=>{measure=callback;return()=>{}}});
const node={properties:{},addDOMWidget(_name,_type,_host,opts){options=opts;return{}}};
renderReactNodeWidget(node,'BV LoRA Registry',{id:'review',name:'review',minHeight:72,maxHeight:340,growWithContent:true,render:()=>null});
measure(900);assert.equal(options.getMinHeight(),240);assert.equal(options.getMaxHeight(),240);
removeReactNodeWidgets(node);
});
+6 -5
View File
@@ -14,6 +14,7 @@ const data=readFileSync(new URL("../ui/src/ui/components/data.tsx",import.meta.u
const content=readFileSync(new URL("../ui/src/ui/components/content.tsx",import.meta.url),"utf8");
const windowSource=readFileSync(new URL("../ui/src/ui/window.tsx",import.meta.url),"utf8");
const styles=readFileSync(new URL("../ui/src/index.css",import.meta.url),"utf8");
const media=readFileSync(new URL("../ui/src/ui/components/MediaPreview.tsx",import.meta.url),"utf8");
const hoverPreview=data.slice(data.indexOf("export function HoverPreview"),data.indexOf("export function IncrementalResourceList"));
test("LoRA registry dialog mounts before catalog IO can finish",()=>{
@@ -26,7 +27,7 @@ test("LoRA registry dialog mounts before catalog IO can finish",()=>{
test("search result media uses the bounded shared BVUI slot",()=>{
assert.match(forms,/className="bv-search-result-media"/);
assert.match(library,/loading="lazy"/);assert.match(library,/decoding="async"/);
assert.match(library,/<MediaPreview/);assert.match(media,/loading=\{active \? "eager" : "lazy"\}/);assert.match(media,/decoding="async"/);
assert.match(styles,/\.bv-search-result-media\{[^}]*inline-size:[^;}]+;[^}]*block-size:[^;}]+;[^}]*overflow:hidden/);
assert.match(styles,/\.bv-search-result-media img\{[^}]*width:100%;[^}]*height:100%;[^}]*object-fit:(?:cover|contain)/);
});
@@ -72,7 +73,7 @@ test("LoRA metadata text keeps the native context menu while window export remai
test("mature or unrated LoRA previews use the persisted global BV preference",()=>{
assert.match(library,/Show mature or unrated previews/);
assert.match(library,/item\.preview_safe\|\|showMature/);
assert.match(library,/safe=\{item\.preview_safe\|\|showMature\}/);
assert.match(library,/policy="mature"/);assert.match(library,/safe=\{item\.preview_safe\}/);
assert.match(library,/useSyncExternalStore\(subscribeMaturePreviewVisibility,getMaturePreviewVisibility,getMaturePreviewVisibility\)/);
assert.match(library,/onValue=\{setMaturePreviewVisibility\}/);
assert.doesNotMatch(library,/\[showMature,setShowMature\]=useState|localStorage|sessionStorage/);
@@ -107,7 +108,7 @@ test("LoRA library delegates bounded incremental rendering to shared BVUI",()=>{
assert.doesNotMatch(library,/MAX_LORA_SEARCH_RESULTS/);
assert.match(data,/export function IncrementalResourceList/);
assert.match(library,/<IncrementalResourceList/);
assert.match(library,/loading="lazy"/);assert.match(library,/decoding="async"/);
assert.match(library,/<MediaPreview/);assert.match(media,/loading=\{active \? "eager" : "lazy"\}/);assert.match(media,/decoding="async"/);
assert.match(styles,/\.bv-incremental-resource-list/);
assert.match(styles,/\.bv-resource-grid/);
});
@@ -144,8 +145,8 @@ test("advanced strength popover and hover preview are centrally themed and bound
assert.match(styles,/\.bv-hover-preview\{[^}]*position:fixed/);
assert.match(styles,/\.bv-hover-preview img\{[^}]*max-width:var\(--bv-hover-preview-max-width/);
assert.doesNotMatch(styles,/\.bv-hover-preview img\{[^}]*object-fit:cover/);
assert.match(hoverPreview,/fitHoverPreviewSize/);assert.match(hoverPreview,/onLoad=/);
assert.match(hoverPreview,/loading="eager"/);assert.doesNotMatch(hoverPreview,/loading="lazy"/);
assert.match(hoverPreview,/fitHoverPreviewSize/);assert.match(hoverPreview,/onDimensions=/);assert.match(media,/onLoad=/);
assert.match(hoverPreview,/<MediaPreview[^]*active onDimensions/);assert.match(media,/loading=\{active \? "eager" : "lazy"\}/);
assert.match(styles,/\.bv-timed-status-slot\{[^}]*min-height/);
});
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {strictLoraRegistryConfig,parseLoraRegistryConfig} from '../ui/src/regional/loraRegistryConfig.ts';
import {loraRegistryResources} from '../ui/src/regional/loraV3Graph.ts';
import {activateNewRegistryDgConsumer} from '../ui/src/regional/loraRegistryDgAdapter.ts';
import {registryChannelFamily,allowedRegistryFamilies} from '../ui/src/regional/registryDgFamilies.ts';
test('basis role survives parsing and is excluded from regular picker',()=>{
const config={schema:'bv.lora_registry_config',version:1,registry_id:crypto.randomUUID(),stacks:[{id:crypto.randomUUID(),name:'Base',role:'basis',enabled:true,entries:[]},{id:crypto.randomUUID(),name:'Style',enabled:true,entries:[]}]};
assert.equal(strictLoraRegistryConfig(config).stacks.find(stack=>stack.name==='Base').role,'basis');
assert.equal('role' in parseLoraRegistryConfig(config).stacks.find(stack=>stack.name==='Style'),false);
assert.deepEqual(loraRegistryResources({type:'BV LoRA Registry',widgets:[{name:'config_json',value:JSON.stringify(config)}]}).map(x=>x.label),['Style']);
config.stacks[0].role='bad';assert.equal(strictLoraRegistryConfig(config),null);
});
test('unpublished model patcher has no Registry transport ownership',()=>{
const node={type:'BV Model Patcher'};activateNewRegistryDgConsumer(node);
assert.deepEqual(allowedRegistryFamilies(node),[]);
assert.equal(node.properties?.bvRegistryDgFamilies,undefined);
assert.equal(registryChannelFamily(node,'base_resource_provider_1'),null);
});
+63
View File
@@ -0,0 +1,63 @@
# Native SmartPipe lifecycle regression
`smart_pipe_lifecycle.html` runs against the installed ComfyUI frontend bundle in an isolated page. It creates detached native graphs and registers a native `LGraphNode` subclass through the real SmartPipe extension's `beforeRegisterNodeDef` and `nodeCreated` hooks. It does not load, queue, or mutate a user's ComfyUI workflow.
Serve `/native/` from the installed frontend `static` directory and `/repo/` from this checkout, bound to localhost. Open `/repo/tests/native/smart_pipe_lifecycle.html` and click **Run detached lifecycle tests**. Results appear as JSON in the page and `globalThis.__nativeSmartPipeResult`.
The bundle filenames and Vue/Pinia aliases target frontend assets `settingStore-CwkLtSKP.js` and `vendor-vue-core-BZZQdWHo.js`. Recheck aliases when upgrading the installed frontend. This fixture is a manually executed native integration test; ordinary `npm test` does not execute it.
Checks cover registered lifecycle callbacks, explicit slot identity serialization, compact visual index 3 mapping to backend ordinal 5, native slot prototypes, complete graph save/load cycles, native subgraph conversion and two instances sharing a definition, external link endpoints, and duplicate/hidden output ghosts.
For original-code comparison, `?original=1` loads four unmodified modules from `/repo/.tmp/smartpipe-original/`: `bv_smart_pipe.js`, `bv_smart_pipe_slots.js`, `bv_smart_pipe_merge_model.js`, and `bv_smart_pipe_routing.js`. Prepare these from the chosen original commit with `git show`; do not replace production modules. The reference original for this regression is `018745f5`.
A passing fixture proves native graph/slot integration for this scenario. It does not prove rendered Classic/Nodes2 hit geometry, the application's undo-history integration, all frontend versions, or a GPU workflow execution.
## Historical explicit-provider Global LoRA acceptance
Run `node tests/native/build_global_lora.cjs` from the repository root with local UI dependencies available. The fixtures use the same `/repo/` and `/native/` static mapping described above. Open `/repo/tests/native/global_lora.html` and click **Run Global LoRA acceptance**. The page exposes JSON in `#result` and `globalThis.__globalLoraResult`.
Before the test, export the actual Native Conditioning contract to `.tmp/native-conditioning-contract.json` using the production class loader from `tests/test_regional_nodes.py`: `load_node_module().BVRegionalNativeConditioningNode`, collecting `INPUT_TYPES()` as `inputs`, `RETURN_TYPES` as `return_types`, and `RETURN_NAMES` as `return_names`. Do not substitute hardcoded output definitions.
The fixture renders production BVUI registry/pickers/catalog with synthetic metadata, opens the real picker popovers, clicks Global and entry switches, checks safe Civitai links without following them, and adds a multipass item to its selected regional target. Native detached graphs preserve registry inventories and occurrence IDs through three root, shared-definition and nested reloads and remove/readd. A native node factory built from the exported backend schema tests the old Conditioning snapshot plus appended MODEL input/output through root and nested reloads.
This proves native LiteGraph serialization and production component behavior. It does not load the complete Comfy app node factory, DG execution bridge, application Undo history, `graphToPrompt`, queue, or GPU. Registry node wrappers and sampler sockets are synthetic fixtures; the parser, resource inventory, BVUI components, and LiteGraph runtime are real.
`global_lora_editor.html` is a manual visual fixture using the shared BVUI theme and production editor. It stores **synthetic** Save results in the test origin's `bv-global-fixture-saved` localStorage key, allowing real Save-button/browser-reload verification. It is not a running ComfyUI workflow. No private media or remote downloads are used.
The historical explicit-provider Global fixture also renders `LoraRegistrySourcesPanel` and tests Global-only Registry selection with no manual stack resources. Production parser/commit/DG reconciliation is invoked on native nodes across three reloads. `global_lora_app.ts` substitutes only the application singleton for detached execution; discovery, config and provider reconciliation stay production code. Visible JSON is a compact total/pass/failure report; `globalThis.__globalLoraResult` retains the full list.
`lora_catalog_client.html` exercises the real singleton fetch/cache/library-loader path with synthetic current API data. `?legacy=1` omits compatibility and civitai_url to model an older running backend. Click **Check client flow and grid**; expected9/9 for both variants. Layout checks and visual review use the regular shared theme and grid cards, without private media. The build helper includes this fixture.
## Current exclusive workflow Global acceptance
The explicit SourcesPanel workflow above documents an earlier intermediate implementation. The current interaction replaces that panel in the Regional Editor with one default-on checkbox immediately above Global Prompt and selects the workflow Global Registry through an exclusive Registry switch.
Run `node tests/native/build_global_lora.cjs`, open `/repo/tests/native/exclusive_global_lora.html` with the static mappings above, and click **Run exclusive Global acceptance**. The final frozen implementation passed **46/46** checks on 2026-09-07. The fixture uses synthetic metadata only.
Coverage includes exclusive switching across root and native subgraphs, shared-definition deduplication, three save/reload cycles, active-copy conflicts without an arbitrary winner, explicit off/on conflict resolution, removal, real visible peer-switch refresh, conflict-callout refresh, two independent production OptionsPanels, default-on and opt-out behavior, manual-provider preservation, and migration of obsolete Regional Prompt `registry_ids` without dangling dependencies. Checkbox placement immediately above Global Prompt is checked geometrically and visually.
The registry views use the production React host and lifecycle with a synthetic native DOM-widget bridge. Config, inventory, discovery, and DG reconciliation are production modules; the installed LiteGraph runtime is real. This does not prove full Comfy application node construction, Undo history, `graphToPrompt`, queue execution, or GPU behavior. The historical explicit-provider fixture is not the current Regional Prompt acceptance gate.
## Registry autogrow and automatic persistence acceptance
Build with `node tests/native/build_global_lora.cjs` and open `/repo/tests/native/registry_ux.html`. Click **Run Registry UX checks**. Final result on 2026-09-08: **23/23 passed**. Page JSON includes actual size measurements.
The installed native LiteGraph node grows from 229 to 402 body pixels (340 widget minimum; title lies outside node.size). Overflow scrolls; manual 520x300 and 560x700 sizes survive refresh, and 560x700 survives native serialization/reload. At the larger manual size, actual content client height expands to 630 pixels, above the automatic cap.
Production Registry dialog checks cover add stack, rename, strength, Undo/Redo, catalog addition, external Global flag synchronization, preservation of a peer change before rerender, and typed strength blur immediately followed by real Close/reopen. Auto-save writes synthetic localStorage only. Catalog windows render in real 2000x1200 and 640x480 iframe viewports: respectively 1600x1050 and clamped 608x448 with 16-pixel margins.
Boundary: production React host/UI/config/presentation modules and installed native graph runtime; native DOM-widget bridge and user-resize signal are synthetic adapters. The bridge uses native widget computeSize and real DOM/ResizeObserver measurement. This does not prove full Comfy canvas pointer hit-testing, app Undo, queue or GPU execution. A final real browser screenshot of the dialog was visually inspected; no private media is used.
## Automatic catalog pagination regression
Build `node tests/native/build_global_lora.cjs`, open `/repo/tests/native/catalog_incremental.html`, then click **Run automatic pagination regression**. Final full-library result: **9/9** on 2026-09-08. The236synthetic-item catalog initially fills its actual grid viewport without pressing Load, scrolls to all236, filters to100 and reaches all matches, survives smaller-window scrolling and larger-window refill, and reaches236 again after clearing. Test actions scroll the production grid; the Load button is never clicked. Real animation frames and DOM-count progress are awaited because fixed60ms pacing can race rendering in a background browser.
**Run initial fill reproduction** runs the original symptom twice. Add `?minimal=1` to isolate only the shared IncrementalResourceList at1200x600 without the catalog/API. Before the fix both full and minimal views were reproducibly stuck8/236 with clientHeight equal to scrollHeight. **Probe scroll trigger** is a test-only causal probe: one scroll event changed8to16 and stayed16, identifying the absent initial/layout trigger. The fix is tested against real React, DOM measurements, ResizeObserver and scrolling; all metadata is synthetic and no private media is loaded.
## Quick Edit spacing and native textarea resizing
Open `/repo/tests/native/quick_prompt_layout.html` after the shared build helper. The synthetic fixture renders the actual QuickPromptEditor. **Measure Quick Edit layout** reports real bounds; **Check scroll reachability** verifies compact references, nonoverlapping fields, reachable positive/negative resize grips and fixed header/footer. **Small Quick Edit window** changes managed geometry; reveal buttons scroll the real outer body. Native textarea grips must also be dragged using the browser UI; style-height injection alone is not acceptance.
Final2026-09-08:5/5checks with both fields enlarged by real native drags,5/5at380x420, and5/5after selecting Region1. Both fields were then actually resized back through their reachable grips. The original reference row was270px and positive/negative overlapped39px with no scroll; after the scoped QuickEdit parent-grid fix references occupy18px and the outer body scrolls. Probe natural Quick Edit rows is an explicit test-only single-variable diagnosis; do not click it for final production verification.
+9
View File
@@ -0,0 +1,9 @@
/** Run from repository root: node tests/native/build_global_lora.cjs */
const path=require('path');
const root=path.resolve(__dirname,'../..');
const esbuild=require(path.join(root,'ui/node_modules/esbuild'));
(async()=>{for(const [entry,output] of [['global_lora.tsx','global-lora-harness.js'],['global_lora_editor.tsx','global-lora-editor.js'],['lora_catalog_client.tsx','lora-catalog-client.js'],['lora_preview.tsx','lora-preview-harness.js'],['exclusive_global_lora.tsx','exclusive-global-lora.js'],['registry_ux.tsx','registry-ux.js'],['catalog_incremental.tsx','catalog-incremental.js'],['quick_prompt_layout.tsx','quick-prompt-layout.js']]){
await esbuild.build({plugins:[{name:"detached-app-singleton",setup(build){build.onResolve({filter:/scripts\/(app|api)\.js$/},()=>({path:path.join(root,"tests/native/global_lora_app.ts")}))}}],absWorkingDir:root,entryPoints:[`tests/native/${entry}`],bundle:true,format:'esm',outfile:`.tmp/${output}`,jsx:'automatic',alias:{react:path.join(root,'ui/node_modules/react'),'react-dom':path.join(root,'ui/node_modules/react-dom')},nodePaths:[path.join(root,'ui/node_modules')]});
}
})().catch(()=>process.exit(1));
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Incremental catalog acceptance</title><link rel="stylesheet" href="/repo/ui/src/index.css"><button id="run">Run initial fill reproduction</button><button id="probe">Probe scroll trigger</button><button id="regression">Run automatic pagination regression</button><div id="mount" class="bv-ui"></div><pre id="result"></pre><script type="module" src="/repo/.tmp/catalog-incremental.js"></script>
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {applyBvTheme} from '../../ui/src/ui/theme';
import {LoraCatalogLibraryWindow} from '../../ui/src/regional/LoraCatalogLibraryWindow';
import {emptyLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
import {IncrementalResourceList} from '../../ui/src/ui/components/data';
applyBvTheme();
const root=createRoot(document.querySelector('#mount')!),cfg=emptyLoraRegistryConfig();
const items=Array.from({length:236},(_,i)=>({name:`synthetic-${String(i).padStart(3,'0')}.safetensors`,display_name:`Synthetic ${i}`,base_model:'Unknown',type:'LoRA',category:'Test',author:'Synthetic',tags:[],trigger_words:[],description:'Synthetic only',preview_url:null,preview_safe:false,compatibility:{status:'unknown',family:null,reason:'Synthetic target',target_model:'unknown'},civitai_url:null}));
const render=()=>root.render(new URLSearchParams(location.search).has('minimal')?<div style={{width:1200,height:600}}><IncrementalResourceList items={items} itemKey={item=>item.name} mode="grid" empty={null} renderItem={item=><article className="bv-resource-result" style={{height:80}}>{item.name}</article>}/></div>:<LoraCatalogLibraryWindow catalog={{schema:'bv.lora_catalog',version:1,items} as any} stacks={cfg.stacks} targetStackId={cfg.stacks[0].id} stateKey="incremental-236-synthetic" onTargetStack={()=>{}} onAdd={()=>true} onClose={()=>root.render(null)}/>);
const wait=async()=>{await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await new Promise(r=>setTimeout(r,120))};
document.querySelector('#regression')!.addEventListener('click',async()=>{
const checks:{name:string,pass:boolean,detail?:unknown}[]=[],check=(name:string,pass:unknown,detail?:unknown)=>checks.push({name,pass:Boolean(pass),detail});
const grid=()=>document.querySelector('.bv-resource-grid') as HTMLElement,count=()=>document.querySelectorAll('.bv-resource-result').length;
const drain=async(target:number)=>{let iterations=0;while(count()<target&&iterations++<40){const before=count(),element=grid();element.scrollTop=element.scrollHeight;element.dispatchEvent(new Event('scroll'));for(let attempt=0;attempt<10&&count()===before;attempt++)await wait();await wait();if(count()===before)break}return{count:count(),iterations}};
const query=async(text:string)=>{const input=document.querySelector<HTMLInputElement>('input[aria-label="Search resources"]')!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,text);input.dispatchEvent(new Event('input',{bubbles:true}));await wait()};
root.render(null);await wait();render();await wait();
try{
check('large initial viewport fills automatically',grid().clientHeight>0&&grid().scrollHeight>grid().clientHeight,{count:count(),client:grid().clientHeight,scroll:grid().scrollHeight});
check('initial render remains incremental',count()<236,{count:count()});
const full=await drain(236);check('scroll reaches all236 without Load button',full.count===236,full);
await query('synthetic-1');check('search resets and automatically fills matching100',grid().clientHeight>0&&grid().scrollHeight>grid().clientHeight&&count()<100,{count:count()});const filtered=await drain(100);check('filtered scroll reaches all100 matches',filtered.count===100&&Array.from(document.querySelectorAll('.bv-resource-result')).every(el=>el.textContent?.includes('synthetic-1')),filtered);
await query('');check('clearing filter restores236 matching status',document.querySelector('.bv-ui-window-status')?.textContent?.includes('236')||document.querySelector('.bv-incremental-resource-list footer')?.textContent?.includes('236'));
const shell=document.querySelector('.bv-managed-window')!;shell.dispatchEvent(new CustomEvent('bv-ui-set-window-state',{detail:{mode:'floating',geometry:{x:16,y:16,width:700,height:500}}}));await wait();await query('synthetic-0');const small=await drain(100);check('small resized catalog scroll reaches100 without Load',small.count===100,small);
await query('');shell.dispatchEvent(new CustomEvent('bv-ui-set-window-state',{detail:{mode:'floating',geometry:{x:16,y:16,width:1600,height:1050}}}));await wait();check('enlarging window refills underfilled viewport',grid().scrollHeight>grid().clientHeight||count()===236,{count:count(),client:grid().clientHeight,scroll:grid().scrollHeight});const final=await drain(236);check('resized cleared catalog reaches all236',final.count===236,final);
}catch(error){check('fixture completes',false,String(error))}
document.querySelector('#result')!.textContent=JSON.stringify({total:checks.length,passed:checks.filter(c=>c.pass).length,checks},null,2);
});
document.querySelector('#probe')!.addEventListener('click',async()=>{const count=()=>document.querySelectorAll('.bv-resource-result').length,before=count();document.querySelector('.bv-resource-grid')?.dispatchEvent(new Event('scroll',{bubbles:false}));await wait();const after=count();await wait();document.querySelector('#result')!.textContent=JSON.stringify({probe:'one scroll event on actual grid',before,after,stable:count()},null,2)});
document.querySelector('#run')!.addEventListener('click',async()=>{
const runs=[];for(let i=0;i<2;i++){root.render(null);await wait();render();await wait();const grid=document.querySelector('.bv-resource-grid') as HTMLElement;const region=document.querySelector('.bv-incremental-resource-list') as HTMLElement;const count=document.querySelectorAll('.bv-resource-result').length;runs.push({pass:grid.clientHeight>0&&(grid.scrollHeight>grid.clientHeight||count===items.length),count,total:items.length,grid:{client:grid?.clientHeight,scroll:grid?.scrollHeight,overflow:getComputedStyle(grid).overflowY},region:{client:region?.clientHeight,scroll:region?.scrollHeight},footer:region?.querySelector('footer')?.textContent})}
document.querySelector('#result')!.textContent=JSON.stringify({pass:runs.every(r=>r.pass),runs},null,2);
});
@@ -0,0 +1,53 @@
import {emptyLoraRegistryConfig,parseLoraRegistryConfig,serializeLoraRegistryConfig,freshenLoraRegistryIdentities,newLoraRegistryStack} from '../../ui/src/regional/loraRegistryConfig';
import {commitLoraV3Config,readNodeLoraV3Config,installLoraV3ConsumerSlot} from '../../ui/src/regional/loraV3Ui';
import {linkedLocalLoraCollectors} from '../../ui/src/regional/loraV3Graph';
import {activateDgReceiver} from '../../ui/src/regional/dgRouting';
import {app} from './global_lora_app';
import {workflowGlobalLoraSelection} from '../../ui/src/regional/loraGlobalRegistry';
import {storeLoraRegistryInventory,installLoraRegistryInventorySource} from '../../ui/src/regional/loraV3Inventory';
import {exclusiveNativeRuntime} from './exclusive_global_native';
export async function runExclusiveGraphChecks(check:(name:string,pass:unknown)=>void,onEditors?:(first:any,second:any)=>Promise<void>){
const {LGraphNode,LiteGraph,SubgraphNode,createGraph}=await exclusiveNativeRuntime();
class Registry extends LGraphNode{constructor(){super('Exclusive Registry fixture');this.comfyClass='BV LoRA Registry';this.serialize_widgets=true;const config=emptyLoraRegistryConfig();config.stacks.find(s=>s.role==='global')!.enabled=false;this.addWidget('text','config_json',serializeLoraRegistryConfig(config),()=>{});}}
installLoraRegistryInventorySource(Registry);
LiteGraph.registerNodeType('BV Test Exclusive Registry',Registry);
let graph=createGraph();const a=LiteGraph.createNode('BV Test Exclusive Registry'),b=LiteGraph.createNode('BV Test Exclusive Registry');graph.add(a);graph.add(b);const aid=a.id,bid=b.id;
const set=(node:any,enabled:boolean)=>{const config=parseLoraRegistryConfig(node.widgets[0].value);config.stacks.find(s=>s.role==='global')!.enabled=enabled;storeLoraRegistryInventory(node,node.widgets[0],serializeLoraRegistryConfig(config),()=>{})};
const read=(node:any)=>parseLoraRegistryConfig(node.widgets[0].value).stacks.find(s=>s.role==='global')!.enabled;
const reload=async()=>{const saved=JSON.parse(JSON.stringify(graph.serialize()));graph=createGraph();graph.configure(saved);await Promise.resolve();};
set(a,true);check('root explicit first switch selects one global',workflowGlobalLoraSelection(a).active.length===1&&read(a)&&!read(b));
set(b,true);check('root explicit second switch deactivates first',workflowGlobalLoraSelection(b).active.length===1&&!read(a)&&read(b));
for(let i=0;i<3;i++){await reload();check(`root reload ${i} retains exclusive choice`,!read(graph.getNodeById(aid))&&read(graph.getNodeById(bid))&&!workflowGlobalLoraSelection(graph.getNodeById(aid)).conflict)}
const conversion=graph.convertToSubgraph(new Set([graph.getNodeById(bid)]));const hid=conversion.node.id;
const inner=()=>graph.getNodeById(hid).subgraph._nodes.find((n:any)=>n.comfyClass==='BV LoRA Registry');
set(graph.getNodeById(aid),true);check('root switch disables nested active registry',!read(inner())&&read(graph.getNodeById(aid)));
set(inner(),true);check('nested switch disables root active registry',read(inner())&&!read(graph.getNodeById(aid)));
const data=JSON.parse(JSON.stringify(graph.getNodeById(hid).serialize()));data.id=-1;const shared=new SubgraphNode(graph,graph.getNodeById(hid).subgraph,data);graph.add(shared);const sid=shared.id;
check('two shared hosts do not create false active Registry conflict',!workflowGlobalLoraSelection(inner()).conflict&&workflowGlobalLoraSelection(inner()).active.length===1);
for(let i=0;i<3;i++){await reload();check(`nested/shared reload ${i} retains exclusive choice`,read(inner())&&!read(graph.getNodeById(aid))&&!workflowGlobalLoraSelection(inner()).conflict);check(`nested/shared reload ${i} retains shared definition`,graph.getNodeById(hid).subgraph===graph.getNodeById(sid).subgraph)}
const copied=LiteGraph.createNode('BV Test Exclusive Registry');copied.widgets[0].value=serializeLoraRegistryConfig(freshenLoraRegistryIdentities(parseLoraRegistryConfig(inner().widgets[0].value)));graph.add(copied);
check('copying active Registry reports conflict without silent winner',workflowGlobalLoraSelection(copied).conflict&&read(copied)&&read(inner()));
const copyId=copied.id;await reload();check('copy conflict survives save/reload instead of arbitrary repair',workflowGlobalLoraSelection(graph.getNodeById(copyId)).conflict);
set(graph.getNodeById(copyId),false);set(graph.getNodeById(copyId),true);
check('explicit copied Registry switch resolves conflict exclusively',read(graph.getNodeById(copyId))&&!read(inner())&&!workflowGlobalLoraSelection(inner()).conflict);
set(graph.getNodeById(copyId),false);check('disabling last Global allows zero active Registry',workflowGlobalLoraSelection(inner()).active.length===0);
graph.remove(graph.getNodeById(copyId));check('removal preserves remaining Registry disabled state',!read(inner())&&!read(graph.getNodeById(aid)));
class Writer extends LGraphNode{constructor(){super('Regional editor fixture');this.comfyClass='BV Regional Prompt';this.serialize_widgets=true;this.properties.bvRegistryDgVersion=1;this.addWidget('text','lora_v3_config_json','{"version":3,"entries":[],"steps":[]}',()=>{})}}
LiteGraph.registerNodeType('BV Test Exclusive Writer',Writer);app.graph=graph;
const first=LiteGraph.createNode('BV Test Exclusive Writer'),second=LiteGraph.createNode('BV Test Exclusive Writer');graph.add(first);graph.add(second);activateDgReceiver(first);activateDgReceiver(second);
const manualRegistry=inner(),manualConfig=parseLoraRegistryConfig(manualRegistry.widgets[0].value),manualStack=newLoraRegistryStack('Manual regional');manualConfig.stacks.push(manualStack);manualRegistry.widgets[0].value=serializeLoraRegistryConfig(manualConfig);
const config:any={version:3,entries:[{id:crypto.randomUUID(),source:{kind:'external',collector_id:manualConfig.registry_id,resource_id:manualStack.id},targets:[{scope:'global'}]}],steps:[]};
set(graph.getNodeById(aid),true);commitLoraV3Config(first,config);commitLoraV3Config(second,config);await new Promise(r=>setTimeout(r,120));
check('both editors default-on discover workflow Global automatically',linkedLocalLoraCollectors(first).includes(graph.getNodeById(aid))&&linkedLocalLoraCollectors(second).includes(graph.getNodeById(aid)));
await onEditors?.(first,second);
commitLoraV3Config(first,{...config,apply_global:false});await new Promise(r=>setTimeout(r,120));
check('one editor optout removes automatic provider only',!linkedLocalLoraCollectors(first).includes(graph.getNodeById(aid))&&linkedLocalLoraCollectors(first).includes(manualRegistry));
check('other editor stays opted in independently',linkedLocalLoraCollectors(second).includes(graph.getNodeById(aid))&&readNodeLoraV3Config(second).apply_global!==false);
const firstId=first.id,secondId=second.id;
for(let i=0;i<3;i++){await reload();app.graph=graph;for(const id of[firstId,secondId]){activateDgReceiver(graph.getNodeById(id));installLoraV3ConsumerSlot(graph.getNodeById(id))}await new Promise(r=>setTimeout(r,120));const f=graph.getNodeById(firstId),s=graph.getNodeById(secondId);check(`editor reload ${i} preserves independent optout`,readNodeLoraV3Config(f).apply_global===false&&readNodeLoraV3Config(s).apply_global!==false);check(`editor reload ${i} preserves manual entry verbatim`,JSON.stringify(readNodeLoraV3Config(f).entries)===JSON.stringify(config.entries));check(`editor reload ${i} keeps correct provider links`,!linkedLocalLoraCollectors(f).includes(graph.getNodeById(aid))&&linkedLocalLoraCollectors(f).includes(inner())&&linkedLocalLoraCollectors(s).includes(graph.getNodeById(aid)));}
const deletedId=crypto.randomUUID(),f=graph.getNodeById(firstId),s=graph.getNodeById(secondId);
commitLoraV3Config(f,{...config,apply_global:false,registry_ids:[deletedId]});commitLoraV3Config(s,{version:3,entries:[],steps:[],apply_global:false,registry_ids:[deletedId]});await new Promise(r=>setTimeout(r,120));
check('obsolete deleted Registry selector is removed from Prompt config',!(readNodeLoraV3Config(f).registry_ids??[]).length&&!(readNodeLoraV3Config(s).registry_ids??[]).length);
check('obsolete selector migration clears dependencies without manual stacks',!linkedLocalLoraCollectors(s).some(Boolean));
check('obsolete selector migration preserves genuine manual dependency',JSON.stringify(readNodeLoraV3Config(f).entries)===JSON.stringify(config.entries)&&linkedLocalLoraCollectors(f).includes(inner()));
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Exclusive workflow Global acceptance</title><link rel="stylesheet" href="/repo/ui/src/index.css"><button id="run">Run exclusive Global acceptance</button><div id="nodes" class="bv-ui"></div><div id="mount" class="bv-ui" style="width:800px;background:var(--bv-ui-surface);padding:12px"></div><pre id="result"></pre><script type="module" src="/repo/.tmp/exclusive-global-lora.js"></script>
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import {runExclusiveGraphChecks} from './exclusive_global_graph_checks';
import {runVisibleRegistryChecks} from './exclusive_global_visible';
import {GlobalLoraApplyControl} from '../../ui/src/regional/GlobalLoraApplyControl';
import {readNodeLoraV3Config} from '../../ui/src/regional/loraV3Ui';
import {createRoot} from 'react-dom/client';
import {flushSync} from 'react-dom';
import OptionsPanel from '../../ui/src/regional/OptionsPanel';
import {applyBvTheme} from '../../ui/src/ui/theme';
applyBvTheme();
const root=createRoot(document.querySelector('#mount')!);
const props:any={mode:'document',region:null,layer:null,bounds:null,canvas:{width:1024,height:1024},globalPrompts:{positive:'Synthetic global scene',negative:''},backgroundPrompts:{positive:'',negative:''},negativeMode:'auto',onNegativeMode:()=>{},onGlobalPrompts:()=>{},onBackgroundPrompts:()=>{},onRegion:()=>{},automaticRegionColor:null,onLayerBounds:()=>{},onBrushSetting:()=>{},promptSections:{global:true,background:false,region:false},onPromptSection:()=>{},loraBindings:{schema:'bv.regional.lora_bindings',version:1,document_id:'fixture',global_stack_id:null,regions:{}},loraStacks:[],onGlobalLoraStack:()=>{},onRegionLoraStack:()=>{},referenceChoices:[],globalTools:[],regionTools:[],onGlobalTools:()=>{},onRegionTools:()=>{}};
flushSync(()=>root.render(<OptionsPanel {...props}/>));
document.querySelector<HTMLButtonElement>('#run')!.onclick=async()=>{
const checks:any[]=[];const check=(name:string,pass:unknown)=>checks.push({name,pass:!!pass});const report:any={status:'running',checks,boundary:'Production OptionsPanel/control/registry store and provider reconciliation; detached native graphs and synthetic DOM widget bridge'};
try{
await runVisibleRegistryChecks(check,document.querySelector<HTMLElement>('#nodes')!);
await runExclusiveGraphChecks(check,async(first,second)=>{
let configs=[readNodeLoraV3Config(first),readNodeLoraV3Config(second)];const before=JSON.stringify(configs[0].entries);
const render=()=>flushSync(()=>root.render(<div style={{display:'flex',gap:20}}>{[first,second].map((node,index)=><section key={index} data-editor={index} style={{width:380}}><OptionsPanel {...props} globalLoraApplyControl={<GlobalLoraApplyControl node={node} config={configs[index]} onConfig={next=>{configs[index]=next;render()}}/>}/></section>)}</div>));
render();const editors=Array.from(document.querySelectorAll<HTMLElement>('[data-editor]'));
const control=(index:number)=>editors[index].querySelector<HTMLInputElement>('input[type="checkbox"][aria-label="Globalen Stack anwenden"]')??Array.from(editors[index].querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).find(el=>el.closest('label')?.textContent?.includes('Globalen Stack anwenden'));
check('both editors default to checked Global application',control(0)?.checked&&control(1)?.checked);
const label=Array.from(editors[0].querySelectorAll('label')).find(el=>el.textContent?.includes('Globalen Stack anwenden')),global=Array.from(editors[0].querySelectorAll('button')).find(el=>el.textContent==='Global');
check('compact checkbox appears directly above Global prompt',label&&global&&!!(label.compareDocumentPosition(global)&Node.DOCUMENT_POSITION_FOLLOWING)&&label.getBoundingClientRect().bottom<=global.getBoundingClientRect().top+2&&global.getBoundingClientRect().top-label.getBoundingClientRect().bottom<35);
check('editor has no Registry source names or IDs or SourcesPanel',editors.every(el=>!el.textContent?.includes('LoRA Registry connections')&&!el.textContent?.includes('Connect LoRA Registry')&&!el.textContent?.includes('Visible Registry')));
control(0)?.click();await new Promise(r=>setTimeout(r,80));
check('real checkbox changes only first editor optout',configs[0].apply_global===false&&configs[1].apply_global!==false&&!control(0)?.checked&&control(1)?.checked);
check('real checkbox preserves manual stack entries',JSON.stringify(configs[0].entries)===before);
});
report.status=checks.every(c=>c.pass)?'passed':'failed';
}catch(error){report.status='failed';report.error=String(error);report.stack=(error as Error).stack}
report.total=checks.length;report.passed=checks.filter(c=>c.pass).length;report.checks=checks.filter(c=>!c.pass);document.querySelector('#result')!.textContent=JSON.stringify(report,null,2);
};
+10
View File
@@ -0,0 +1,10 @@
/** Detached installed LiteGraph helper, without application workflow state. */
export async function exclusiveNativeRuntime(){
const vueUrl='/native/assets/vendor-vue-core-BZZQdWHo.js',nativeUrl='/native/assets/settingStore-CwkLtSKP.js';
const vue:any=await import(/* @vite-ignore */vueUrl);vue.g({render(){return null}}).use(vue.l());
const values:any[]=Object.values(await import(/* @vite-ignore */nativeUrl));
const LGraph=values.find(v=>typeof v==='function'&&v.name==='LGraph'),LGraphNode=values.find(v=>typeof v==='function'&&v.name==='LGraphNode'),LiteGraph=values.find(v=>v&&typeof v.registerNodeType==='function'&&typeof v.createNode==='function'),SubgraphNode=values.find(v=>typeof v==='function'&&v.prototype?.isSubgraphNode?.());
if(!LGraph||!LGraphNode||!LiteGraph||!SubgraphNode)throw Error('Installed native frontend contract unavailable');
const createGraph=()=>{const graph=new LGraph();graph.events.addEventListener('subgraph-created',(event:any)=>{const sub=event.detail.subgraph;class Bound extends SubgraphNode{constructor(){super(graph,sub,{id:-1,type:sub.id,pos:[0,0],size:[200,100],flags:{},order:0,mode:0})}}LiteGraph.registerNodeType(sub.id,Bound)});return graph};
return{LGraph,LGraphNode,LiteGraph,SubgraphNode,createGraph};
}
+26
View File
@@ -0,0 +1,26 @@
import {exclusiveNativeRuntime} from './exclusive_global_native';
import {installLoraRegistryUi} from '../../ui/src/regional/loraRegistryUi';
import {emptyLoraRegistryConfig,serializeLoraRegistryConfig,parseLoraRegistryConfig,freshenLoraRegistryIdentities} from '../../ui/src/regional/loraRegistryConfig';
export async function runVisibleRegistryChecks(check:(name:string,pass:unknown)=>void,mount:HTMLElement){
const {LGraphNode,LiteGraph,createGraph}=await exclusiveNativeRuntime();
class Registry extends LGraphNode{
constructor(){super('Visible Registry');this.comfyClass='BV LoRA Registry';this.serialize_widgets=true;const cfg=emptyLoraRegistryConfig();cfg.stacks.find(s=>s.role==='global')!.enabled=false;this.addWidget('text','config_json',serializeLoraRegistryConfig(cfg),()=>{})}
addDOMWidget(name:string,_type:string,host:HTMLElement,options:any){const section=document.createElement('section');section.dataset.fixtureNode=String(this.id);section.appendChild(host);mount.appendChild(section);return this.addWidget('text',name,'',()=>{},options)}
}
const savedFetch=window.fetch;window.fetch=async()=>({ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:[]})} as Response);
try{
installLoraRegistryUi(Registry,{name:'BV LoRA Registry'},{apiURL:(p:string)=>p});LiteGraph.registerNodeType('BV LoRA Registry',Registry);
const graph=createGraph(),a=LiteGraph.createNode('BV LoRA Registry'),b=LiteGraph.createNode('BV LoRA Registry');graph.add(a);graph.add(b);await new Promise(r=>setTimeout(r,150));
const state=(node:any)=>parseLoraRegistryConfig(node.widgets.find((w:any)=>w.name==='config_json').value).stacks.find(s=>s.role==='global')!.enabled;
const switches=()=>Array.from(mount.querySelectorAll<HTMLButtonElement>('[role="switch"][aria-label="Global stack"]'));
check('both production native Registry views visibly mounted',switches().length===2);
switches()[0]?.click();await new Promise(r=>setTimeout(r,80));check('first visible switch activation shows checked state',switches()[0]?.getAttribute('aria-checked')==='true'&&state(a));
switches()[1]?.click();await new Promise(r=>setTimeout(r,80));check('peer visible switch refreshes after exclusive activation',switches()[0]?.getAttribute('aria-checked')==='false'&&switches()[1]?.getAttribute('aria-checked')==='true'&&!state(a)&&state(b));
const copy=LiteGraph.createNode('BV LoRA Registry');copy.widgets.find((w:any)=>w.name==='config_json').value=serializeLoraRegistryConfig(freshenLoraRegistryIdentities(parseLoraRegistryConfig(b.widgets.find((w:any)=>w.name==='config_json').value)));graph.add(copy);await new Promise(r=>setTimeout(r,100));
check('copied active Registry shows explicit visible conflict',mount.textContent?.includes('Multiple active Global Registries')&&state(copy)&&state(b));
switches()[2]?.click();await new Promise(r=>setTimeout(r,60));switches()[2]?.click();await new Promise(r=>setTimeout(r,80));
check('explicit conflict resolution refreshes all visible switches',switches()[0]?.getAttribute('aria-checked')==='false'&&switches()[1]?.getAttribute('aria-checked')==='false'&&switches()[2]?.getAttribute('aria-checked')==='true');
check('resolved conflict removes warning from all Registry views',!mount.textContent?.includes('Multiple active Global Registries'));
graph.remove(a);graph.remove(b);graph.remove(copy);await Promise.resolve();check('Registry removal disposes visible React hosts',mount.querySelectorAll('[data-bv-node-widget]').length===0);
}finally{window.fetch=savedFetch;mount.replaceChildren()}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Isolated automatic Global LoRA acceptance</title><button id="run" disabled>Run Global LoRA acceptance</button><div id="mount"></div><pre id="result">Loading</pre><script type="module" src="/repo/.tmp/global-lora-harness.js"></script>
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import {nativeGlobalLoraChecks} from './global_lora_graph';
import {loraRegistryResources} from '../../ui/src/regional/loraV3Graph';
import {LoraV3ScopePicker} from '../../ui/src/regional/LoraV3ResourcePickerPanel';
import {LoraCatalogLibraryWindow} from '../../ui/src/regional/LoraCatalogLibraryWindow';
import {LoraRegistrySourcesPanel} from '../../ui/src/regional/LoraRegistrySourcesPanel';
import {emptyLoraV3Config} from '../../ui/src/regional/loraV3Config';
import {createRoot} from 'react-dom/client';
import {flushSync} from 'react-dom';
import {LoraRegistryNodeView} from '../../ui/src/regional/LoraRegistryNodeView';
import {emptyLoraRegistryConfig,newLoraRegistryStack,parseLoraRegistryConfig,serializeLoraRegistryConfig,newLoraRegistryEntry} from '../../ui/src/regional/loraRegistryConfig';
const result=document.querySelector('#result')!;
const button=document.querySelector<HTMLButtonElement>('#run')!;
button.onclick=async()=>{
const report:any={status:'running',checks:[],boundary:'Real browser DOM, production BVUI; no Comfy queue or GPU'};
const check=(name:string,pass:unknown)=>report.checks.push({name,pass:!!pass});
const mount=document.querySelector('#mount')!;const root=createRoot(mount);
try{
let config=emptyLoraRegistryConfig();
const globals=()=>config.stacks.filter((s:any)=>s.role==='global');
check('new registry has exactly one automatic global stack',globals().length===1);
check('automatic global starts empty',globals().length===1&&globals()[0].entries.length===0);
const render=()=>flushSync(()=>root.render(<LoraRegistryNodeView stored={serializeLoraRegistryConfig(config)} onStored={value=>{config=parseLoraRegistryConfig(value);render()}} onOpenLibrary={()=>{}}/>));
render();
check('node displays explicit automatic global label',mount.textContent?.includes('Global · Automatically applied'));
check('node explains additive regional behavior',mount.textContent?.includes('Applied to the entire generation. Regional stacks are added on top.'));
const toggle=mount.querySelector<HTMLButtonElement>('[role="switch"]');
if(toggle&&globals().length){toggle.click();check('native click disables global stack',!globals()[0].enabled);toggle.click();check('native click enables global stack',globals()[0].enabled)}
else check('global switch exists',false);
config.stacks.push(newLoraRegistryStack('Regional costume'));
if(globals()[0])globals()[0].entries.push(newLoraRegistryEntry('synthetic.safetensors'),newLoraRegistryEntry('synthetic.safetensors'));
const before=serializeLoraRegistryConfig(config);
for(let i=0;i<3;i++)config=parseLoraRegistryConfig(serializeLoraRegistryConfig(config));
render();check('three serialization reloads preserve exact inventory',serializeLoraRegistryConfig(config)===before);
check('reload retains deliberate duplicate occurrences',globals()[0]?.entries.length===2&&globals()[0]?.entries[0].id!==globals()[0]?.entries[1].id);
check('normal stack remains displayed',mount.textContent?.includes('Regional costume'));
if(globals()[0]){
const entries=mount.querySelectorAll<HTMLButtonElement>('[role="switch"][aria-label="synthetic"]');
entries[0]?.click();check('individual entry disable preserves intentional duplicate',globals()[0].entries[0].enabled===false&&globals()[0].entries[1].enabled===true);
}
const regional=config.stacks.find(s=>s.name==='Regional costume')!;
const resources=loraRegistryResources({comfyClass:'BV LoRA Registry',widgets:[{name:'config_json',value:serializeLoraRegistryConfig(config)}]})??[];
for(const target of [{scope:'global' as const},{scope:'region' as const,document_id:'fixture-document',region_id:'fixture-region'}]){
flushSync(()=>root.render(<LoraV3ScopePicker collectors={[{id:config.registry_id,label:'Registry',resources}]} config={{version:3,entries:[{id:'fixture-entry',source:{kind:'external',collector_id:config.registry_id,resource_id:regional.id},targets:[target]}]}} target={target} resolved onSelection={()=>{}} onAdd={()=>{}} onRemove={()=>{}}/>));
flushSync(()=>mount.querySelector<HTMLButtonElement>('[aria-haspopup="listbox"]')?.click());
const choices=Array.from(document.querySelectorAll('[role="option"]')).map(o=>o.textContent);
check(`${target.scope} prompt actual picker keeps regional choice`,choices.includes('Regional costume'));
check(`${target.scope} prompt actual picker excludes automatic Global`,!choices.some(label=>label?.includes('Global')));
flushSync(()=>root.render(null));
}
const item:any={name:'fixture.safetensors',display_name:'Synthetic multipass LoRA',base_model:'Krea 2',tags:[],trigger_words:[],author:'',description:'Synthetic local fixture',size:1,preview_url:null,preview_safe:true,metadata_sources:[],type:'LoRA',category:'',directory:'',civitai_url:'https://civitai.com/models/123?modelVersionId=456',compatibility:{status:'multipass',family:'krea2',reason:'Global time layers require regional multipass.',target_model:'unknown'}};
let added:any=null;
const library=(value:any)=>flushSync(()=>root.render(<LoraCatalogLibraryWindow catalog={{schema:'bv.lora_catalog',version:1,items:[value]}} stacks={config.stacks} stateKey={crypto.randomUUID()} targetStackId={regional.id} onTargetStack={()=>{}} onAdd={(name,target)=>{added={name,target};return true}} onClose={()=>{}}/>));
library(item);
check('catalog explicitly offers regional multipass',mount.textContent?.includes('Multipass'));
check('catalog target model remains honestly Unknown',mount.textContent?.includes('Exact target-model compatibility is checked at execution.'));
const link=mount.querySelector<HTMLAnchorElement>('a');
check('catalog uses exact supplied Civitai URL',link?.href===item.civitai_url&&link.rel.includes('noopener'));
flushSync(()=>Array.from(mount.querySelectorAll('button')).find(b=>b.textContent==='Add')?.click());
check('multipass Add preserves regional target and never silently moves Global',added?.target===regional.id&&added?.name===item.name);
for(const url of ['javascript:alert(1)','https://civitai.com.evil.test/models/123','https://evil.test/models/123']){
flushSync(()=>root.render(null));library({...item,civitai_url:url,compatibility:{status:'unknown'}});
check(`catalog rejects untrusted link ${url}`,!mount.querySelector('a'));
check('unknown compatibility is visible',mount.textContent?.includes('Not checked'));
}
flushSync(()=>root.render(null));
await nativeGlobalLoraChecks(check,(node,id)=>{
let selected:any=emptyLoraV3Config();
const renderSources=()=>flushSync(()=>root.render(<LoraRegistrySourcesPanel node={node} config={selected} onConfig={value=>{selected=value;renderSources()}}/>));
renderSources();
check('Registry source panel explains automatic Global with optional regional stacks',mount.textContent?.includes('Connected Registries apply their Global LoRAs automatically.'));
flushSync(()=>mount.querySelector<HTMLButtonElement>('[aria-label="Connect LoRA Registry"]')?.click());
const option=Array.from(document.querySelectorAll<HTMLButtonElement>('[role="option"]')).find(el=>el.textContent?.includes('Synthetic Registry'));
check('actual Registry selector offers global-only Registry',!!option);
flushSync(()=>option?.click());
if(!selected.registry_ids?.includes(id))report.providerDiagnostic={selected,expected:id,text:mount.textContent};
check('actual Registry selector click persists dependency without manual entries',selected.registry_ids?.includes(id)&&selected.entries.length===0);
flushSync(()=>root.render(null));
});
report.status=report.checks.every((c:any)=>c.pass)?'passed':'failed';
}catch(error){report.status='failed';report.error=String(error);report.stack=(error as Error).stack}
finally{root.unmount()}
result.textContent=JSON.stringify({...report,total:report.checks.length,passed:report.checks.filter((c:any)=>c.pass).length,checks:report.checks.filter((c:any)=>!c.pass)},null,2);(window as any).__globalLoraResult=report;
};button.disabled=false;result.textContent='Ready';
+4
View File
@@ -0,0 +1,4 @@
// Detached harness adapter for the application singleton only.
// Production registry/provider reconciliation and native graphs are unchanged.
export const app:any={graph:null,canvas:null};
export const api:any={};
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Global LoRA editor visual acceptance (synthetic)</title><link rel="stylesheet" href="/repo/ui/src/index.css"><div id="mount" class="bv-ui"></div><pre id="result"></pre><script type="module" src="/repo/.tmp/global-lora-editor.js"></script>
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import {applyBvTheme} from '../../ui/src/ui/theme';
applyBvTheme();
import {createRoot} from 'react-dom/client';
import {LoraRegistryDialogView} from '../../ui/src/regional/LoraRegistryView';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry,serializeLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
const config=emptyLoraRegistryConfig();
const basis=newLoraRegistryStack('Legacy style');basis.role='basis';basis.entries.push(newLoraRegistryEntry('synthetic-style.safetensors'));
config.stacks.push(basis,newLoraRegistryStack('Regional costume'));
const root=createRoot(document.querySelector('#mount')!);
root.render(<LoraRegistryDialogView stored={localStorage.getItem('bv-global-fixture-saved')??serializeLoraRegistryConfig(config)} catalog={{schema:'bv.lora_catalog',version:1,items:[]}} save={value=>{localStorage.setItem('bv-global-fixture-saved',value);document.querySelector('#result')!.textContent='Saved synthetic fixture'}} close={()=>root.unmount()}/>);
+59
View File
@@ -0,0 +1,59 @@
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry,parseLoraRegistryConfig,serializeLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
import {loraRegistryResources,linkedLocalLoraCollectors} from '../../ui/src/regional/loraV3Graph';
import {commitLoraV3Config,readNodeLoraV3Config,installLoraV3ConsumerSlot} from '../../ui/src/regional/loraV3Ui';
import {activateDgReceiver} from '../../ui/src/regional/dgRouting';
import {app as detachedApp} from './global_lora_app';
export async function nativeGlobalLoraChecks(check:(name:string,pass:unknown)=>void,onProvider?:(node:any,id:string)=>void){
const vueUrl='/native/assets/vendor-vue-core-BZZQdWHo.js',nativeUrl='/native/assets/settingStore-CwkLtSKP.js';
const vue:any=await import(/* @vite-ignore */vueUrl);const app=vue.g({render(){return null}});app.use(vue.l());
const native:any=await import(/* @vite-ignore */nativeUrl);
const values:any[]=Object.values(native),LGraph=values.find(v=>typeof v==='function'&&v.name==='LGraph'),LGraphNode=values.find(v=>typeof v==='function'&&v.name==='LGraphNode'),LiteGraph=values.find(v=>v&&typeof v.registerNodeType==='function'&&typeof v.createNode==='function'),SubgraphNode=values.find(v=>typeof v==='function'&&v.prototype?.isSubgraphNode?.());
check('actual native graph and subgraph available',LGraph&&LGraphNode&&SubgraphNode&&LiteGraph);
const contract=await(await fetch('/repo/.tmp/native-conditioning-contract.json')).json();
check('actual backend retains positive0 negative1 and appends patched_model2',JSON.stringify(contract.return_names)===JSON.stringify(['positive','negative','patched_model']));
check('actual backend optional MODEL follows legacy registry and bindings',JSON.stringify(Object.keys(contract.inputs.optional))===JSON.stringify(['lora_registry','lora_bindings','model']));
const createGraph=()=>{const g=new LGraph();g.events.addEventListener('subgraph-created',(event:any)=>{const sub=event.detail.subgraph;class Bound extends SubgraphNode{constructor(){super(g,sub,{id:-1,type:sub.id,pos:[0,0],size:[200,100],flags:{},order:0,mode:0})}}LiteGraph.registerNodeType(sub.id,Bound)});return g};
class Registry extends LGraphNode{
constructor(){super('Synthetic Registry');this.comfyClass='BV LoRA Registry';this.serialize_widgets=true;this.addWidget('text','config_json',serializeLoraRegistryConfig(emptyLoraRegistryConfig()),()=>{});this.addOutput('fixture','STRING')}
onConfigure(){this.widgets[0].value=serializeLoraRegistryConfig(parseLoraRegistryConfig(this.widgets[0].value))}
}
class Consumer extends LGraphNode{constructor(){super('Synthetic Consumer');this.addInput('fixture','STRING')}}
LiteGraph.registerNodeType('BV Test Global Registry',Registry);LiteGraph.registerNodeType('BV Test Global Consumer',Consumer);
let graph=createGraph();const registry=LiteGraph.createNode('BV Test Global Registry'),consumer=LiteGraph.createNode('BV Test Global Consumer');graph.add(registry);graph.add(consumer);registry.connect(0,consumer,0);
const config=emptyLoraRegistryConfig(),global=config.stacks.find((s:any)=>s.role==='global'),regional=newLoraRegistryStack('Regional costume');config.stacks.push(regional);
if(global)global.entries.push(newLoraRegistryEntry('fixture.safetensors'),newLoraRegistryEntry('fixture.safetensors'));
registry.widgets[0].value=serializeLoraRegistryConfig(config);const stable=(value:any):string=>JSON.stringify(value,(_key,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item);const canonical=stable(config);
const inspect=(node:any,label:string)=>{const parsed=parseLoraRegistryConfig(node.widgets[0].value);check(`${label}: exact inventory and occurrence IDs survive`,stable(parsed)===canonical);check(`${label}: exactly one global`,parsed.stacks.filter((s:any)=>s.role==='global').length===1);check(`${label}: picker exposes normal stack only`,JSON.stringify(loraRegistryResources(node)?.map(r=>r.id))===JSON.stringify([regional.id]));check(`${label}: native output object retained`,typeof node.outputs[0].toJSON==='function')};
inspect(registry,'root');const rid=registry.id;
const reload=()=>{const saved=JSON.parse(JSON.stringify(graph.serialize()));graph=createGraph();graph.configure(saved)};
for(let i=0;i<3;i++){reload();inspect(graph.getNodeById(rid),`root reload ${i}`)}
let current=graph.getNodeById(rid);const converted=graph.convertToSubgraph(new Set([current]));let host=converted.node;const hid=host.id;
const copyData=JSON.parse(JSON.stringify(host.serialize()));copyData.id=-1;for(const p of copyData.outputs??[])p.links=null;for(const p of copyData.inputs??[])p.link=null;const copy=new SubgraphNode(graph,host.subgraph,copyData);graph.add(copy);const cid=copy.id;
for(let i=0;i<3;i++){reload();host=graph.getNodeById(hid);const second=graph.getNodeById(cid);check(`shared reload ${i}: both hosts share exact definition`,host.subgraph===second.subgraph);inspect(host.subgraph._nodes.find((n:any)=>n.type==='BV Test Global Registry'),`shared reload ${i}`)}
const nested=graph.convertToSubgraph(new Set([graph.getNodeById(hid)]));const nid=nested.node.id;
for(let i=0;i<3;i++){reload();const outer=graph.getNodeById(nid),inner=outer.subgraph._nodes.find((n:any)=>n.isSubgraphNode?.());inspect(inner.subgraph._nodes.find((n:any)=>n.type==='BV Test Global Registry'),`nested reload ${i}`)}
const outer=graph.getNodeById(nid),inner=outer.subgraph._nodes.find((n:any)=>n.isSubgraphNode?.()),reg=inner.subgraph._nodes.find((n:any)=>n.type==='BV Test Global Registry');
const before=reg.outputs[0];reg.disconnectOutput(0);check('disconnect preserves native output identity',reg.outputs[0]===before);const savedReg=JSON.parse(JSON.stringify(reg.serialize()));inner.subgraph.remove(reg);const restored=LiteGraph.createNode('BV Test Global Registry');inner.subgraph.add(restored);restored.configure(savedReg);inspect(restored,'remove and readd');
let legacy=true;
class Conditioning extends LGraphNode{constructor(){super('Native Conditioning contract fixture');for(const [name,spec] of Object.entries({...contract.inputs.required,...contract.inputs.optional}) as any){if(typeof spec[0]==='string'&&!['FLOAT','INT','STRING','BOOLEAN'].includes(spec[0])&&(!legacy||name!=='model'))this.addInput(name,spec[0])}contract.return_names.forEach((name:string,i:number)=>{if(!legacy||i<2)this.addOutput(name,contract.return_types[i])})}}
class Sink extends LGraphNode{constructor(){super('Sampler contract fixture');this.addInput('positive','CONDITIONING');this.addInput('negative','CONDITIONING');this.addInput('model','MODEL')}}
LiteGraph.registerNodeType('BV Test Native Conditioning',Conditioning);LiteGraph.registerNodeType('BV Test Sampler',Sink);
graph=createGraph();const conditioning=LiteGraph.createNode('BV Test Native Conditioning'),sink=LiteGraph.createNode('BV Test Sampler');graph.add(conditioning);graph.add(sink);conditioning.connect(0,sink,0);conditioning.connect(1,sink,1);
const conditioningId=conditioning.id,sinkId=sink.id;legacy=false;reload();
const upgraded=graph.getNodeById(conditioningId);check('native old snapshot retains legacy input order and appended MODEL',JSON.stringify(upgraded.inputs.map((p:any)=>p.name))===JSON.stringify(['regional','clip','lora_registry','lora_bindings','model']));
check('native old snapshot retains original output names and MODEL tail',JSON.stringify(upgraded.outputs.map((p:any)=>p.name))===JSON.stringify(contract.return_names));upgraded.connect(2,graph.getNodeById(sinkId),2);
const inspectConditioning=(node:any,label:string)=>{check(`${label}: output order remains backend order`,JSON.stringify(node.outputs.map((p:any)=>p.name))===JSON.stringify(contract.return_names));check(`${label}: MODEL input stays after existing ports`,node.inputs[4]?.name==='model'&&node.inputs[2]?.name==='lora_registry'&&node.inputs[3]?.name==='lora_bindings');check(`${label}: output slots are native instances`,node.outputs.every((p:any)=>typeof p.toJSON==='function'));const owner=node.graph;for(let i=0;i<3;i++){const id=node.outputs[i].links?.[0],link=owner._links?.get(id)??owner.links?.get?.(id);check(`${label}: linked backend output ${i} retained`,link?.origin_slot===i)}};
for(let i=0;i<3;i++){reload();inspectConditioning(graph.getNodeById(conditioningId),`conditioning root reload ${i}`)}
const convertedCondition=graph.convertToSubgraph(new Set([graph.getNodeById(conditioningId)])),conditionHostId=convertedCondition.node.id;
for(let i=0;i<3;i++){reload();inspectConditioning(graph.getNodeById(conditionHostId).subgraph._nodes.find((n:any)=>n.type==='BV Test Native Conditioning'),`conditioning nested reload ${i}`)}
class Writer extends LGraphNode{constructor(){super('Automatic Global consumer fixture');this.comfyClass='BV Regional LoRA';this.serialize_widgets=true;this.properties.bvRegistryDgVersion=1;this.addWidget('text','config_json','{"version":3,"entries":[]}',()=>{})}}
LiteGraph.registerNodeType('BV Test Automatic Writer',Writer);
graph=createGraph();detachedApp.graph=graph;const automatic=LiteGraph.createNode('BV Test Global Registry'),writer=LiteGraph.createNode('BV Test Automatic Writer');graph.add(automatic);graph.add(writer);activateDgReceiver(writer);
const autoConfig=emptyLoraRegistryConfig(),autoRegistryId=autoConfig.registry_id;automatic.widgets[0].value=serializeLoraRegistryConfig(autoConfig);
onProvider?.(writer,autoRegistryId);
const selection:any={version:3,entries:[],steps:[],registry_ids:[autoRegistryId]};commitLoraV3Config(writer,selection);await new Promise(r=>setTimeout(r,120));
check('auto-only registry selection survives actual production config parser',JSON.stringify((readNodeLoraV3Config(writer) as any).registry_ids)===JSON.stringify([autoRegistryId]));
check('production reconciliation links auto-only Registry without manual entries',linkedLocalLoraCollectors(writer).includes(automatic));
const automaticId=automatic.id,writerId=writer.id;
for(let i=0;i<3;i++){reload();detachedApp.graph=graph;const w=graph.getNodeById(writerId);activateDgReceiver(w);installLoraV3ConsumerSlot(w);await new Promise(r=>setTimeout(r,120));check(`auto-only reload ${i}: production reconciliation preserves Registry link`,linkedLocalLoraCollectors(w).includes(graph.getNodeById(automaticId)));check(`auto-only reload ${i}: no manual stack selection invented`,readNodeLoraV3Config(w).entries.length===0);check(`auto-only reload ${i}: registry ID preserved`,JSON.stringify((readNodeLoraV3Config(w) as any).registry_ids)===JSON.stringify([autoRegistryId]));}
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Synthetic LoRA catalog API-to-UI acceptance</title><link rel="stylesheet" href="/repo/ui/src/index.css"><button id="check" style="position:fixed;z-index:9999;top:0;left:0">Check client flow and grid</button><div id="mount" class="bv-ui"></div><pre id="result" style="position:fixed;z-index:9999;bottom:0;background:white;max-height:200px;overflow:auto"></pre><script type="module" src="/repo/.tmp/lora-catalog-client.js"></script>
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {flushSync} from 'react-dom';
import {LoraCatalogLibraryLoader} from '../../ui/src/regional/LoraRegistryView';
import {loraCatalogClient} from '../../ui/src/regional/loraCatalogClient';
import {emptyLoraRegistryConfig,serializeLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
import {applyBvTheme} from '../../ui/src/ui/theme';
applyBvTheme();
const mount=document.querySelector('#mount')!,result=document.querySelector('#result')!;
const root=createRoot(mount),config=emptyLoraRegistryConfig();let stored=serializeLoraRegistryConfig(config),requests:string[]=[];
const base:any={base_model:'Krea 2',tags:['character','style'],trigger_words:[],author:'Synthetic',description:'Synthetic metadata only; no remote media.',size:1,preview_url:null,preview_safe:true,metadata_sources:['synthetic.json'],type:'LoRA',category:'Character',directory:'Synthetic'};
const items=[{...base,name:'Synthetic/00-multipass.safetensors',display_name:'00 Synthetic long descriptive multipass character LoRA',civitai_url:'https://civitai.com/models/123?modelVersionId=456',compatibility:{status:'multipass',family:'krea2',reason:'Temporal targets require regional multipass.',target_model:'unknown'}},{...base,name:'Synthetic/01-token.safetensors',display_name:'01 Synthetic token candidate',civitai_url:'https://civitai.com/models/234',compatibility:{status:'token_candidate',family:'krea2',reason:'Token candidate; target model unverified.',target_model:'unknown'}},{...base,name:'Synthetic/02-unknown.safetensors',display_name:'02 Synthetic unknown',civitai_url:null,compatibility:{status:'unknown',family:null,reason:'Unsupported header targets.',target_model:'unknown'}}];
const legacy=new URLSearchParams(location.search).has('legacy');
const payloadItems=structuredClone(items) as any[];if(legacy)for(const item of payloadItems){delete item.compatibility;delete item.civitai_url}
const originalFetch=window.fetch;
window.fetch=async(input:any)=>{if(String(input)!=='/synthetic-api/bv_nodepack/loras/catalog')throw Error('Unexpected fixture request '+input);requests.push(String(input));return {ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:payloadItems})} as Response};
loraCatalogClient.invalidate();
flushSync(()=>root.render(<LoraCatalogLibraryLoader api={{apiURL:path=>'/synthetic-api'+path}} readStored={()=>stored} targetStackId={config.stacks[0].id} save={value=>{stored=value}} close={()=>root.unmount()}/>));
const checkButton=document.querySelector<HTMLButtonElement>('#check')!;
checkButton.onclick=async()=>{
const checks:any[]=[];const check=(name:string,pass:unknown)=>checks.push({name,pass:!!pass});
check('actual singleton client made expected API request',requests.length>0&&requests.every(p=>p==='/synthetic-api/bv_nodepack/loras/catalog'));
const cached=loraCatalogClient.getSnapshot();check('API compatibility survives client cache',legacy?!('compatibility' in cached.items[0]):cached.items[0]?.compatibility?.status==='multipass');check('API Civitai URL survives client cache',legacy?!('civitai_url' in cached.items[0]):cached.items[0]?.civitai_url===items[0].civitai_url);
check('actual loader renders exact Civitai link',legacy?!mount.querySelector('a'):mount.querySelector<HTMLAnchorElement>('a')?.href===items[0].civitai_url);
check('actual loader shows known multipass routing',legacy?!mount.textContent?.includes('Multipass'):mount.textContent?.includes('Multipass'));
check('actual loader shows token candidate routing',legacy?!mount.textContent?.includes('Token candidate'):mount.textContent?.includes('Token candidate'));
flushSync(()=>mount.querySelector<HTMLButtonElement>('[aria-label="Grid view"]')?.click());
await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
check('old payload gets one actionable backend update notice',legacy?Array.from(mount.querySelectorAll('.bv-callout')).filter(el=>el.textContent?.includes('Catalog backend update required')).length===1:!mount.textContent?.includes('Catalog backend update required'));
const lines=Array.from(mount.querySelectorAll<HTMLElement>('.bv-resource-result small')).filter(el=>/^(Multipass|Token candidate|Not checked)$/.test(el.textContent??''));
check('routing lines remain readable at card width',legacy?lines.length===0:lines.length===3&&lines.every(el=>el.scrollWidth<=el.clientWidth+1));
const badges=Array.from(mount.querySelectorAll<HTMLElement>('.bv-resource-result .bv-badge')).filter(el=>/Regional:|Compatibility:/.test(el.textContent??''));
check('routing badges do not squeeze or clip in grid cards',badges.every(el=>el.scrollWidth<=el.clientWidth+1));
result.textContent=JSON.stringify({status:checks.every(c=>c.pass)?'passed':'failed',checks,badgeWidths:badges.map(el=>({label:el.textContent,width:el.clientWidth,contentWidth:el.scrollWidth})),boundary:'Real client fetch/cache/loader/components with synthetic API and no media'},null,2);
};
window.addEventListener('beforeunload',()=>{window.fetch=originalFetch});
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Isolated LoRA preview lifecycle</title><button id="run" disabled>Run preview lifecycle</button><div id="mount"></div><pre id="result">Loading</pre><script type="module" src="/repo/.tmp/lora-preview-harness.js"></script>
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {flushSync} from 'react-dom';
import {MediaPreview} from '../../ui/src/ui/components/MediaPreview';
import {LoraRegistryNodeView} from '../../ui/src/regional/LoraRegistryNodeView';
import {loraCatalogClient} from '../../ui/src/regional/loraCatalogClient';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry} from '../../ui/src/regional/loraRegistryConfig';
import {setMaturePreviewVisibility} from '../../ui/src/ui/preferences';
const result=document.querySelector('#result')!;
const button=document.querySelector<HTMLButtonElement>('#run')!;
button.onclick=async()=>{
const report:any={status:'running',checks:[]};const check=(name:string,value:unknown)=>{report.checks.push({name,pass:!!value});if(!value)throw Error(name)};
const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const container=document.querySelector('#mount')!;const root=createRoot(container);
const previousFetch=window.fetch;
try{
window.fetch=async()=>({ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:[{name:'fixture.safetensors',display_name:'Synthetic video preview',base_model:'test',tags:[],trigger_words:[],author:'',description:'',size:1,preview_url:'data:video/mp4;base64,AAAA',preview_media_type:'video',preview_safe:false,metadata_sources:[],type:'LoRA',category:'',directory:''}]})}) as Response;
await loraCatalogClient.reload({apiURL:p=>p});window.fetch=previousFetch;
const config=emptyLoraRegistryConfig(),stack=newLoraRegistryStack();stack.entries.push(newLoraRegistryEntry('fixture.safetensors'));config.stacks.push(stack);
setMaturePreviewVisibility(false,false);root.render(<LoraRegistryNodeView stored={JSON.stringify(config)} onStored={()=>{}} onOpenLibrary={()=>{}}/>);await wait();
const anchor=()=>container.querySelector<HTMLElement>('.bv-hover-preview-anchor')!;
check('unsafe preview initially not focusable',anchor().getAttribute('tabindex')===null);
setMaturePreviewVisibility(true,false);await wait();check('mounted real node responds to Mature on',anchor().tabIndex===0);
anchor().focus();await wait(550);const video=document.querySelector<HTMLVideoElement>('.bv-hover-preview video');
check('actual shared hover renders video or explicit decode failure',document.querySelector('.bv-hover-preview') && (video || document.querySelector('.bv-media-preview-error')) && document.querySelector('.bv-hover-preview img')===null);
if(video) check('hover video is muted looping and inline',video.muted&&video.loop&&video.playsInline);
setMaturePreviewVisibility(false,false);await wait();check('Mature off removes open video',!document.querySelector('.bv-hover-preview video'));
check('Mature off removes focus eligibility',anchor().getAttribute('tabindex')===null);
setMaturePreviewVisibility(true,false);await wait();anchor().blur();anchor().focus();await wait(30);root.unmount();await wait(550);
check('unmount cancels pending hover and removes media',!document.querySelector('.bv-hover-preview'));
const mediaRoot=createRoot(container);
const renderMedia=(src:string,active=false)=>flushSync(()=>mediaRoot.render(<MediaPreview src={src} mediaType="video" label="Synthetic" active={active}/>));
renderMedia('data:video/mp4;base64,AAAA');
const card=container.querySelector('video')!;
check('card video stays metadata-only without autoplay',card.preload==='metadata'&&!card.autoplay&&!!container.querySelector('.bv-media-preview-badge'));
renderMedia('data:video/mp4;base64,AAAA',true);
const active=container.querySelector('video')!;
check('active video attributes are muted inline looping autoplay',active.muted&&active.loop&&active.playsInline&&active.autoplay);
flushSync(()=>active.dispatchEvent(new Event('error')));
check('media error is visible and removes failed video',!!container.querySelector('.bv-media-preview-error')&&!container.querySelector('video'));
renderMedia('data:video/mp4;base64,BBBB');check('source B resets error',!!container.querySelector('video'));
renderMedia('data:video/mp4;base64,AAAA');check('return to source A resets error',!!container.querySelector('video'));
mediaRoot.unmount();check('media unmount removes video',!container.querySelector('video'));
report.status='passed';report.mediaBoundary='synthetic invalid video URI tests DOM/lifecycle; decoding/playback not claimed';
}catch(error){report.status='failed';report.error=String(error);report.stack=(error as Error).stack;try{root.unmount()}catch{}}
finally{window.fetch=previousFetch;setMaturePreviewVisibility(false,false);loraCatalogClient.invalidate();}
result.textContent=JSON.stringify(report,null,2);(window as any).__loraPreviewResult=report;
};button.disabled=false;result.textContent='Ready';
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Quick Edit layout acceptance</title><link rel="stylesheet" href="/repo/ui/src/index.css?quickedit=20260908-final"><button id="measure">Measure Quick Edit layout</button><button id="scroll">Scroll Quick Edit body to end</button><button id="probe">Probe natural Quick Edit rows</button><button id="small">Small Quick Edit window</button><button id="positive">Reveal positive resize</button><button id="negative">Reveal negative resize</button><button id="validate">Check scroll reachability</button><div id="mount" class="bv-ui"></div><pre id="result"></pre><script type="module" src="/repo/.tmp/quick-prompt-layout.js"></script>
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import QuickPromptEditor from '../../ui/src/regional/QuickPromptEditor';
import {emptyDocument,newRegion} from '../../ui/src/regional/model';
import {loadEditorState,saveEditorState} from '../../ui/src/regional/editorState';
import {applyBvTheme} from '../../ui/src/ui/theme';
import {app} from './global_lora_app';
applyBvTheme();
const documentValue=emptyDocument();documentValue.prompts.global={positive_source:'Synthetic global positive',negative_source:'Synthetic negative'};documentValue.prompts.background={positive_source:'Synthetic background',negative_source:''};documentValue.regions.push(newRegion(0));
saveEditorState(documentValue.document_id,{...loadEditorState(documentValue.document_id),quickPromptWindow:{x:300,y:80,width:540,height:800}});
const graph:any={_nodes:[],setDirtyCanvas(){}};graph.rootGraph=graph;app.graph=graph;
const node:any={id:1,type:'BV Regional Prompt',comfyClass:'BV Regional Prompt',title:'Synthetic Quick Edit',graph,widgets:[{name:'regional_json',value:JSON.stringify(documentValue),callback(){}},{name:'lora_v3_config_json',value:JSON.stringify({version:3,entries:[],steps:[]}),callback(){}}]};graph._nodes.push(node);
window.fetch=async()=>({ok:true,json:async()=>({items:[]})} as Response);
createRoot(document.querySelector('#mount')!).render(<QuickPromptEditor open activationToken={1} activityScope={graph} nodes={[node]} initialNode={node} loraStacks={[]} onClose={()=>{}} onOpenEditor={()=>{}}/>);
const rect=(el:Element|null)=>{const r=el?.getBoundingClientRect();return r?{x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom,right:r.right}:null};
const measure=()=>{const body=document.querySelector('.bv-managed-window-body') as HTMLElement,quick=document.querySelector('.bv-quick-prompt-body') as HTMLElement,fields=Array.from(document.querySelectorAll('textarea')),toggle=document.querySelector('.bv-reference-search-toggle'),global=Array.from(document.querySelectorAll('label')).find(el=>el.textContent?.includes('Globalen Stack anwenden'));const data={body:{...rect(body),client:body?.clientHeight,scroll:body?.scrollHeight,top:body?.scrollTop,overflow:getComputedStyle(body).overflowY},quick:rect(quick),global:rect(global??null),references:rect(toggle),positive:rect(fields[0]),negative:rect(fields[1]),positiveResize:getComputedStyle(fields[0]).resize,header:rect(document.querySelector('.bv-ui-window-header')),footer:rect(document.querySelector('.bv-ui-window-footer'))};document.querySelector('#result')!.textContent=JSON.stringify(data,null,2)};
document.querySelector('#measure')!.addEventListener('click',measure);document.querySelector('#scroll')!.addEventListener('click',()=>{const body=document.querySelector('.bv-managed-window-body')!;body.scrollTop=body.scrollHeight;measure()});
document.querySelector('#probe')!.addEventListener('click',()=>{const style=document.createElement('style');style.textContent='.bv-quick-prompt-body{grid-template-rows:none;align-content:start;height:auto}';document.head.appendChild(style);measure()});
document.querySelector('#small')!.addEventListener('click',()=>{document.querySelector('.bv-managed-window')!.dispatchEvent(new CustomEvent('bv-ui-set-window-state',{detail:{mode:'floating',geometry:{x:300,y:80,width:380,height:420}}}));setTimeout(measure,100)});
for(const [name,index] of [['positive',0],['negative',1]] as const)document.querySelector(`#${name}`)!.addEventListener('click',()=>{document.querySelectorAll('textarea')[index].scrollIntoView({block:'end'});measure()});
document.querySelector('#validate')!.addEventListener('click',()=>{const body=document.querySelector('.bv-managed-window-body') as HTMLElement,fields=Array.from(document.querySelectorAll('textarea')),checks:any[]=[],check=(name:string,pass:unknown,detail?:unknown)=>checks.push({name,pass:Boolean(pass),detail});const header=document.querySelector('.bv-ui-window-header')!.getBoundingClientRect(),footer=document.querySelector('.bv-ui-window-footer')!.getBoundingClientRect();body.scrollTop=0;const ref=document.querySelector('.bv-reference-search-toggle')!.getBoundingClientRect(),p=fields[0].getBoundingClientRect(),n=fields[1].getBoundingClientRect();check('reference control is compact',ref.height<=30,{height:ref.height});check('positive and negative do not overlap',n.top>=p.bottom,{positiveBottom:p.bottom,negativeTop:n.top});for(const [i,field] of fields.entries()){field.scrollIntoView({block:'end'});const b=body.getBoundingClientRect(),f=field.getBoundingClientRect();check(`textarea ${i} resize grip reachable by outer scroll`,f.bottom<=b.bottom+1&&f.bottom>b.top+8,{fieldBottom:f.bottom,bodyTop:b.top,bodyBottom:b.bottom,scrollTop:body.scrollTop});}check('header and footer remain fixed while body scrolls',document.querySelector('.bv-ui-window-header')!.getBoundingClientRect().y===header.y&&document.querySelector('.bv-ui-window-footer')!.getBoundingClientRect().y===footer.y);document.querySelector('#result')!.textContent=JSON.stringify({total:checks.length,passed:checks.filter(c=>c.pass).length,checks},null,2)});
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Registry UX isolated acceptance</title><link rel="stylesheet" href="/repo/ui/src/index.css"><button id="run">Run Registry UX checks</button><button id="open">Open synthetic Registry</button><div id="nodes" class="bv-ui"></div><div id="mount" class="bv-ui"></div><pre id="result"></pre><script type="module" src="/repo/.tmp/registry-ux.js"></script>
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import {createRoot} from 'react-dom/client';
import {applyBvTheme} from '../../ui/src/ui/theme';
import {registryUxNative} from './registry_ux_native';
import {LoraRegistryDialogView} from '../../ui/src/regional/LoraRegistryView';
import {LoraCatalogLibraryWindow} from '../../ui/src/regional/LoraCatalogLibraryWindow';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry,parseLoraRegistryConfig,serializeLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
applyBvTheme();
const wait=()=>new Promise(r=>setTimeout(r,120));
const mount=document.querySelector('#mount')!,root=createRoot(mount),result=document.querySelector('#result')!;
const initial=emptyLoraRegistryConfig();const normal=newLoraRegistryStack('Synthetic style');normal.entries.push(newLoraRegistryEntry('synthetic.safetensors'));initial.stacks.push(normal);
const catalog:any={schema:'bv.lora_catalog',version:1,items:[{name:'synthetic-new.safetensors',display_name:'Synthetic new LoRA',base_model:'Unknown',type:'LoRA',category:'Test',author:'Synthetic',tags:[],trigger_words:[],description:'Test only',preview_url:null,preview_safe:false,compatibility:{status:'unknown',family:null,reason:'No target model',target_model:'unknown'},civitai_url:null}]};
let stored=serializeLoraRegistryConfig(initial),saves=0;
const cfg=()=>parseLoraRegistryConfig(stored);
const buttons=(name:string)=>Array.from(document.querySelectorAll<HTMLButtonElement>('button')).filter(b=>b.textContent?.trim()===name);
const click=async(name:string)=>{buttons(name)[0]?.click();await wait()};
const save=(value:string)=>{stored=value;saves++;localStorage.setItem('bv-registry-ux-synthetic',value)};
const open=()=>root.render(<LoraRegistryDialogView stored={stored} catalog={catalog} save={save} readStored={()=>stored} close={()=>root.render(null)}/>);
document.querySelector('#open')!.addEventListener('click',open);
const checks:{name:string;pass:boolean;detail?:unknown}[]=[];
const check=(name:string,pass:unknown,detail?:unknown)=>checks.push({name,pass:Boolean(pass),detail});
const report=()=>{result.textContent=JSON.stringify({total:checks.length,passed:checks.filter(c=>c.pass).length,failures:checks.filter(c=>!c.pass),measurements:checks.filter(c=>c.detail),saves},null,2);(globalThis as any).__registryUxResult={checks,stored}};
const mode=new URLSearchParams(location.search).get('mode');
if(mode==='catalog'){
root.render(<LoraCatalogLibraryWindow catalog={{...catalog,items:Array.from({length:12},(_,i)=>({...catalog.items[0],name:`synthetic-layout-${i}.safetensors`,display_name:`Synthetic layout ${i}`}))}} stacks={initial.stacks} stateKey="isolated-catalog-ux" targetStackId={normal.id} onTargetStack={()=>{}} onAdd={()=>true} onClose={()=>root.render(null)}/>);
setTimeout(()=>{const box=document.querySelector('[role="dialog"]')!.getBoundingClientRect(),grid=document.querySelector('.bv-resource-grid')!,detail=document.querySelector('.bv-resource-detail')!;const data={width:box.width,height:box.height,left:box.left,top:box.top,right:box.right,bottom:box.bottom,viewport:[innerWidth,innerHeight],columns:getComputedStyle(grid).gridTemplateColumns,detailWidth:detail.getBoundingClientRect().width,detailDisplay:getComputedStyle(detail).display};parent.postMessage({registryUxCatalog:data},location.origin);result.textContent=JSON.stringify(data)},600);
}else if(mode==='layout'){
const measurements:any[]=[];(async()=>{for(const [width,height] of [[2000,1200],[640,480]]){const frame=document.createElement('iframe');frame.width=String(width);frame.height=String(height);frame.src=location.pathname+'?mode=catalog';document.body.appendChild(frame);const data:any=await new Promise(resolve=>{const listener=(event:MessageEvent)=>{if(event.source===frame.contentWindow&&event.data?.registryUxCatalog){window.removeEventListener('message',listener);resolve(event.data.registryUxCatalog)}};window.addEventListener('message',listener)});measurements.push(data);frame.remove()}result.textContent=JSON.stringify(measurements,null,2)})();
}else document.querySelector('#run')!.addEventListener('click',async()=>{
checks.length=0;stored=serializeLoraRegistryConfig(initial);saves=0;root.render(null);await wait();await registryUxNative(check,document.querySelector('#nodes')!);open();await wait();
try{
check('no explicit Save registry button',buttons('Save registry').length===0);
await click('Add named stack');check('add stack autosaves immediately',cfg().stacks.length===3);
const input=Array.from(document.querySelectorAll<HTMLInputElement>('input')).find(el=>el.value==='LoRA Stack 3');
if(input){Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,'Renamed synthetic');input.dispatchEvent(new Event('input',{bubbles:true}));await wait()}
check('rename autosaves',cfg().stacks.some(s=>s.name==='Renamed synthetic'));
const strength=document.querySelector<HTMLInputElement>('input[aria-label="Synthetic style group strength"]');strength?.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowUp',bubbles:true}));await wait();
check('strength increment autosaves',cfg().stacks.find(s=>s.name==='Synthetic style')?.entries[0]?.model_strength===1.05);
await click('More');await click('Undo');check('undo persists previous strength',cfg().stacks.find(s=>s.name==='Synthetic style')?.entries[0]?.model_strength===1);
await click('More');await click('Redo');check('redo persists strength again',cfg().stacks.find(s=>s.name==='Synthetic style')?.entries[0]?.model_strength===1.05);
root.render(null);await wait();open();await wait();check('close reopen retains renamed stack and strength',Array.from(document.querySelectorAll<HTMLInputElement>('input')).some(el=>el.value==='Renamed synthetic')&&cfg().stacks.find(s=>s.name==='Synthetic style')?.entries[0]?.model_strength===1.05);
const peer=cfg();peer.stacks[0].enabled=true;stored=serializeLoraRegistryConfig(peer);open();await wait();
check('external Global switch visibly syncs in open dialog',document.querySelector('[role="switch"]')?.getAttribute('aria-checked')==='true');
await click('More');await click('Undo');check('undo does not restore obsolete peer Global flag',cfg().stacks[0].enabled===true);
const latePeer=cfg();latePeer.stacks[0].enabled=false;stored=serializeLoraRegistryConfig(latePeer);
document.querySelector<HTMLInputElement>('input[aria-label="Synthetic style group strength"]')?.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowUp',bubbles:true}));await wait();
check('next edit preserves a peer change before rerender',cfg().stacks[0].enabled===false);
buttons('+ Add LoRA')[1]?.click();await wait();await click('Add');check('catalog add autosaves new entry',cfg().stacks.find(s=>s.name==='Synthetic style')?.entries.some(e=>e.lora_name==='synthetic-new.safetensors'));
const library=document.querySelector('[role="dialog"][aria-label^="Add LoRA"]');Array.from(library?.querySelectorAll<HTMLButtonElement>('button')??[]).find(b=>b.textContent==='Close')?.click();await wait();
const typed=document.querySelector<HTMLInputElement>('input[aria-label="Synthetic style group strength"]');
if(typed){typed.focus();Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(typed,'2');typed.dispatchEvent(new Event('input',{bubbles:true}));await wait();typed.blur();document.querySelector<HTMLButtonElement>('[role="dialog"][aria-label="LoRA Registry"] button[aria-label="Close window"]')?.click()}
await wait();check('typed strength blur immediately followed by Close persists',cfg().stacks.find(s=>s.name==='Synthetic style')?.entries.every(e=>e.model_strength===2));
open();await wait();check('reopening after real Close shows persisted typed strength',document.querySelector<HTMLInputElement>('input[aria-label="Synthetic style group strength"]')?.value==='2');
root.render(null);await wait();
for(const [width,height] of [[2000,1200],[640,480]]){
const frame=document.createElement('iframe');frame.width=String(width);frame.height=String(height);frame.src=location.pathname+'?mode=catalog';document.body.appendChild(frame);
const data:any=await new Promise(resolve=>{const listener=(e:MessageEvent)=>{if(e.source===frame.contentWindow&&e.data?.registryUxCatalog){window.removeEventListener('message',listener);resolve(e.data.registryUxCatalog)}};window.addEventListener('message',listener)});
check(`catalog ${width}x${height} is viewport bounded`,data.left>=0&&data.top>=0&&data.right<=width&&data.bottom<=height,data);
if(width===2000)check('catalog large viewport starts 1600x1050',data.width===1600&&Math.abs(data.height-1050)<=2,data);
frame.remove();
}
}catch(error){check('fixture completes',false,String(error))}finally{report();open()}
});
+36
View File
@@ -0,0 +1,36 @@
import {exclusiveNativeRuntime} from './exclusive_global_native';
import {installLoraRegistryUi} from '../../ui/src/regional/loraRegistryUi';
import {emptyLoraRegistryConfig,newLoraRegistryStack,newLoraRegistryEntry,parseLoraRegistryConfig,serializeLoraRegistryConfig} from '../../ui/src/regional/loraRegistryConfig';
import {storeLoraRegistryInventory} from '../../ui/src/regional/loraV3Inventory';
import {configurePresentationSizeLifecycle} from '../../ui/src/regional/presentationSize';
const wait=()=>new Promise(r=>setTimeout(r,180));
export async function registryUxNative(check:(name:string,pass:unknown,detail?:unknown)=>void,mount:HTMLElement){
const {LGraphNode,LiteGraph,createGraph}=await exclusiveNativeRuntime();
let resizing:any=null;configurePresentationSizeLifecycle({isUserResizing:node=>node===resizing});
class Registry extends LGraphNode{
fixtureHost?:HTMLElement;
constructor(){super('Synthetic Registry');this.comfyClass='BV LoRA Registry';this.serialize_widgets=true;this.size=[360,180];this.addWidget('text','config_json',serializeLoraRegistryConfig(emptyLoraRegistryConfig()),()=>{})}
addDOMWidget(name:string,_type:string,host:HTMLElement,options:any){this.fixtureHost=host;host.style.width=`${this.size[0]}px`;mount.appendChild(host);const widget=this.addWidget('text',name,'',()=>{},options);widget.computeSize=(width:number)=>[width,options.getMinHeight()];widget.element=host;return widget}
setSize(size:any){const value=super.setSize(size);if(this.fixtureHost){this.fixtureHost.style.width=`${this.size[0]}px`;this.fixtureHost.style.height=`${Math.max(72,this.size[1]-70)}px`}return value}
}
const previous=window.fetch;window.fetch=async()=>({ok:true,json:async()=>({schema:'bv.lora_catalog',version:1,items:[]})} as Response);
try{
installLoraRegistryUi(Registry,{name:'BV LoRA Registry'},{apiURL:(path:string)=>path});LiteGraph.registerNodeType('BV LoRA Registry',Registry);
const graph=createGraph(),node=LiteGraph.createNode('BV LoRA Registry');graph.add(node);await wait();const originalHeight=node.size[1];
const widget=node.widgets.find((w:any)=>w.name==='config_json');const content=()=>node.fixtureHost.querySelector('.bv-react-node-widget-content') as HTMLElement;
const stored=parseLoraRegistryConfig(widget.value);stored.stacks[0].entries.push(...Array.from({length:3},(_,i)=>newLoraRegistryEntry(`global-${i}.safetensors`)));const stack=newLoraRegistryStack('Regional style');stack.entries.push(...Array.from({length:5},(_,i)=>newLoraRegistryEntry(`regional-${i}.safetensors`)));stored.stacks.push(stack);
const write=()=>storeLoraRegistryInventory(node,widget,serializeLoraRegistryConfig(stored),()=>{});
write();await wait();const grown=node.size[1];
check('native Registry grows when two stacks gain entries',grown>originalHeight,{originalHeight,grown});
check('automatic total node height remains approximately 440 cap',grown>=350&&grown<=460,{grown,widgetMin:node.widgets.find((w:any)=>w.name==='lora_registry_widget').options.getMinHeight()});
check('content over cap scrolls instead of unbounded growth',content().scrollHeight>content().clientHeight,{scroll:content().scrollHeight,client:content().clientHeight});
const resize=(width:number,height:number)=>{resizing=node;node.setSize([width,height]);resizing=null};
resize(520,300);stored.stacks[1].entries.push(newLoraRegistryEntry('extra.safetensors'));write();await wait();
check('manual smaller height and explicit width survive content refresh',node.size[0]===520&&node.size[1]===300,{size:[...node.size]});
resize(560,700);stored.stacks[1].entries.pop();write();await wait();
check('manual larger height above automatic cap is retained',node.size[0]===560&&node.size[1]===700,{size:[...node.size]});
check('manual larger node exposes more content than automatic cap',content().clientHeight>340,{client:content().clientHeight,scroll:content().scrollHeight});
const snapshot=graph.serialize();graph.configure(snapshot);await wait();const restored=graph.getNodeById(node.id);check('manual height and width survive native save reload',restored.size[0]===560&&restored.size[1]===700,{size:[...restored.size]});
graph.clear();await wait();
}finally{window.fetch=previous;configurePresentationSizeLifecycle({});mount.replaceChildren()}
}
+141
View File
@@ -0,0 +1,141 @@
<!doctype html><meta charset="utf-8"><title>SmartPipe native lifecycle regression</title>
<button id="run" disabled>Run detached lifecycle tests</button><pre id="result">Loading test module</pre>
<script type="module">
const result=document.querySelector('#result');
document.querySelector('#run').onclick=async()=>{
const report={status:'running',checks:[],detached:true};
const check=(name,value)=>{report.checks.push({name,pass:!!value});if(!value)throw Error(name)};
const settle=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
try{
report.phase='native import';
const vue=await import('/native/assets/vendor-vue-core-BZZQdWHo.js');
const isolatedVueApp=vue.g({render(){return null}});isolatedVueApp.use(vue.l());
const native=await import('/native/assets/settingStore-CwkLtSKP.js');
const LGraph=Object.values(native).find(v=>typeof v==='function'&&v.name==='LGraph');
const LGraphNode=Object.values(native).find(v=>typeof v==='function'&&v.name==='LGraphNode');
const LiteGraph=Object.values(native).find(v=>v&&typeof v.registerNodeType==='function'&&typeof v.createNode==='function');
check('real native graph and registry exports',LGraph&&LGraphNode&&LiteGraph);
const SubgraphNode=Object.values(native).find(v=>typeof v==='function'&&v.prototype?.isSubgraphNode&&v.prototype.isSubgraphNode());
check('real native SubgraphNode export',SubgraphNode);
const createGraph=()=>{
const g=new LGraph();
g.events.addEventListener('subgraph-created',event=>{
const sub=event.detail.subgraph;
class BoundSubgraph extends SubgraphNode{constructor(){super(g,sub,{id:-1,type:sub.id,pos:[0,0],size:[200,100],flags:{},order:0,mode:0});}}
LiteGraph.registerNodeType(sub.id,BoundSubgraph);
});
return g;
};
const schema=[1,2,3,4,5,6].map(n=>({id:`id-${n}`,ordinal:n,name:`slot-${n}`,type:'STRING',showOutput:n!==3&&n!==4,showInput:false}));
let extension;globalThis.__detachedSmartPipeApp={graph:createGraph(),registerExtension(e){extension=e}};
report.phase='extension capture';
const original=new URLSearchParams(location.search).has('original');
const url=new URL(original?'/repo/.tmp/smartpipe-original/bv_smart_pipe.js':'/repo/js/bv_smart_pipe.js',location.href);
let text=await(await fetch(url)).text();
text=text.replace('import { app } from "../../scripts/app.js";','const app=globalThis.__detachedSmartPipeApp;').replace(/from "(\.\/[^\"]+)"/g,(_,p)=>`from ${JSON.stringify(new URL(p,url).href)}`);
text+='\nexport {reconcilePorts,remapSmartPipeOutputLinks};';
const blob=URL.createObjectURL(new Blob([text],{type:'text/javascript'}));const mod=await import(blob);URL.revokeObjectURL(blob);
const slotState=await import('/repo/js/bv_node_slots.js');
const connectionEvents=[];
let configured=0,serialized=0;
class Pipe extends LGraphNode{
constructor(){super('Native SmartPipe');this.addInput('pipe','BV_SMART_PIPE');this.addOutput('pipe','BV_SMART_PIPE');for(let n=1;n<=100;n++)this.addOutput(`out_${String(n).padStart(3,'0')}`,'STRING');this.addWidget('text','bv_smart_pipe_schema_json','[]',()=>{});this.addWidget('text','bv_smart_pipe_route_json','{}',()=>{});extension.nodeCreated(this);}
onConnectionsChange(){connectionEvents.push({id:String(this.id),guard:slotState.nativeSlotsAreConfiguring(this)});}
onConfigure(){configured++}
onSerialize(){serialized++}
}
class Consumer extends LGraphNode{constructor(){super('Native Consumer');this.addInput('regional','STRING')}}
await extension.beforeRegisterNodeDef(Pipe,{name:'BV Smart Pipe'});
LiteGraph.registerNodeType('BV Smart Pipe',Pipe);LiteGraph.registerNodeType('BV Test Consumer',Consumer);
let graph=globalThis.__detachedSmartPipeApp.graph;
const source=LiteGraph.createNode('BV Smart Pipe'),target=LiteGraph.createNode('BV Test Consumer');
source.properties.bvSmartPipe={version:1,localSlots:structuredClone(schema),inheritedSlots:[],resolvedSlots:structuredClone(schema),routing:{nodeId:'source',name:'Source',mode:'root'}};
graph.add(source);graph.add(target);await extension.nodeCreated(source);await settle();
const regionalIndex=source.outputs.findIndex(p=>p.name==='out_005');source.connect(regionalIndex,target,0);
// Explicitly hide an already linked output. Native configure replays its link;
// replay must not turn the persisted visibility back on as if newly connected.
schema.find(p=>p.id==='id-5').showOutput=false;
for(const list of ['localSlots','resolvedSlots'])source.properties.bvSmartPipe[list].find(p=>p.id==='id-5').showOutput=false;
mod.reconcilePorts(source,schema);
const sourceId=source.id,targetId=target.id;
check('fixture has compact regional visual index three',regionalIndex===3);
const inspect=(label)=>{
const s=graph.getNodeById(sourceId),t=graph.getNodeById(targetId),linkId=t.inputs[0].link;
const links=graph._links||graph.links,link=links.get?.(linkId)||links[linkId];
check(label + ": replay preserves hidden linked output flag",s.properties.bvSmartPipe.localSlots.find(p=>p.id==='id-5').showOutput===false);
check(`${label}: linked endpoint is Regional`,link&&s.outputs[link.origin_slot]?.name==='out_005');
check(`${label}: no duplicate or hidden ghost outputs`,s.outputs.length===1+schema.filter(p=>p.showOutput||p.id==='id-5').length&&new Set(s.outputs.map(p=>p.name)).size===s.outputs.length);
check(`${label}: actual native output prototype`,typeof s.outputs[link.origin_slot].toJSON==='function');
const api={consumer:{class_type:'Consumer',inputs:{regional:['20:17',link.origin_slot]}}};
mod.remapSmartPipeOutputLinks(api,{descriptors:[{node:s,address:'test',executionId:'20:17'}],registry:{test:{projection:{resolvedSlots:schema}}}});
check(`${label}: materialized ordinal five`,api.consumer.inputs.regional[1]===5);
};
inspect('initial');
for(let round=0;round<3;round++){
report.phase=`whole graph reload ${round}`;
const saved=JSON.parse(JSON.stringify(graph.serialize()));
report.savedNodeSummary=saved.nodes.map(n=>({id:n.id,type:n.type,outputs:n.outputs?.map(p=>p.name)}));
check(`round ${round}: extension onSerialize ran`,serialized>round);
check(`round ${round}: explicit identity persisted`,saved.nodes.find(n=>String(n.id)===String(sourceId))?.outputs?.find(p=>p.name==='out_005')?.bvSlotId==='id-5');
graph=createGraph();globalThis.__detachedSmartPipeApp.graph=graph;
const replayStart=connectionEvents.length;graph.configure(saved);await settle();
const replayEvents=connectionEvents.slice(replayStart);report.lastReplayEvents=replayEvents;
check('native original callbacks execute inside configure guard',replayEvents.length>0&&replayEvents.every(e=>e.guard));
check(`round ${round}: extension onConfigure ran`,configured>round);
inspect(`reload ${round}`);
}
const syncSchema=()=>{const n=graph.getNodeById(sourceId);n.properties.bvSmartPipe.localSlots=structuredClone(schema);n.properties.bvSmartPipe.resolvedSlots=structuredClone(schema);mod.reconcilePorts(n,schema);};
const reloadCurrent=async(label)=>{const saved=JSON.parse(JSON.stringify(graph.serialize()));graph=createGraph();globalThis.__detachedSmartPipeApp.graph=graph;graph.configure(saved);await settle();inspect(label);};
report.phase='native slot mutation matrix';
schema.splice(2,0,{id:'new-7',ordinal:7,name:'inserted-middle',type:'STRING',showOutput:true,showInput:false});syncSchema();await reloadCurrent('add middle ordinal7');
schema.splice(schema.findIndex(p=>p.id==='new-7'),1);syncSchema();await reloadCurrent('remove unlinked ordinal7');
schema.splice(2,0,{id:'new-8',ordinal:8,name:'replacement-middle',type:'STRING',showOutput:true,showInput:false});syncSchema();await reloadCurrent('readd new identity ordinal8');
const rename=schema.find(p=>p.id==='new-8');rename.name='renamed replacement';syncSchema();await reloadCurrent('rename stable identity');
rename.showOutput=false;syncSchema();await reloadCurrent('hide unlinked replacement');
rename.showOutput=true;syncSchema();await reloadCurrent('show replacement');
const beforeSnapshot=JSON.parse(JSON.stringify(graph.serialize())),beforeSchema=structuredClone(schema);
schema.splice(schema.findIndex(p=>p.id==='new-8'),1);syncSchema();const afterSnapshot=JSON.parse(JSON.stringify(graph.serialize())),afterSchema=structuredClone(schema);
for(const [label,snapshot,state] of [['snapshot undo',beforeSnapshot,beforeSchema],['snapshot redo',afterSnapshot,afterSchema]]){
schema.splice(0,schema.length,...structuredClone(state));graph=createGraph();globalThis.__detachedSmartPipeApp.graph=graph;graph.configure(snapshot);await settle();inspect(label);
}
report.historyBoundary='native graph snapshots restored; application Undo UI not exercised';
report.phase='native boundary conversion';
const converted=graph.convertToSubgraph(new Set([graph.getNodeById(sourceId)]));
await settle();
const host=converted.node,subgraph=converted.subgraph;
check('native conversion made one real subgraph boundary',host.subgraph===subgraph&&host.outputs.length>0);
const copyData=JSON.parse(JSON.stringify(host.serialize()));copyData.id=-1;
for(const p of copyData.outputs||[])p.links=null;
for(const p of copyData.inputs||[])p.link=null;
const copy=new SubgraphNode(graph,subgraph,copyData);graph.add(copy);
const target2=LiteGraph.createNode('BV Test Consumer');graph.add(target2);copy.connect(0,target2,0);
const hostId=host.id,copyId=copy.id,target2Id=target2.id;
const inspectHosts=(label)=>{
const a=graph.getNodeById(hostId),b=graph.getNodeById(copyId);
check(`${label}: two native instances share definition`,a.subgraph===b.subgraph);
for(const [h,t] of [[a,graph.getNodeById(targetId)],[b,graph.getNodeById(target2Id)]]){
const links=graph._links||graph.links,id=t.inputs[0].link,link=links.get?.(id)||links[id];
check(`${label}: instance ${h.id} keeps external endpoint`,link&&link.origin_id===h.id&&h.outputs[link.origin_slot].links.includes(id));
}
const pipe=a.subgraph._nodes.find(n=>n.type==='BV Smart Pipe');
check(`${label}: shared inner pipe no ghosts`,pipe&&pipe.outputs.length===5&&new Set(pipe.outputs.map(p=>p.name)).size===5);
const index=pipe.outputs.findIndex(p=>p.name==='out_005');
const descriptors=[a,b].map(h=>({node:pipe,address:`host-${h.id}`,executionId:`${h.id}:${pipe.id}`}));
const registry=Object.fromEntries(descriptors.map(d=>[d.address,{projection:{resolvedSlots:schema}}]));
const api=Object.fromEntries(descriptors.map((d,i)=>[`consumer${i}`,{class_type:'Consumer',inputs:{regional:[d.executionId,index]}}]));
mod.remapSmartPipeOutputLinks(api,{descriptors,registry});
check(`${label}: both expanded execution paths map ordinal five`,Object.values(api).every(n=>n.inputs.regional[1]===5));
};
inspectHosts('converted');
for(let round=0;round<2;round++){
const saved=JSON.parse(JSON.stringify(graph.serialize()));graph=createGraph();globalThis.__detachedSmartPipeApp.graph=graph;
const replayStart=connectionEvents.length;graph.configure(saved);await settle();
const replayEvents=connectionEvents.slice(replayStart);report.lastReplayEvents=replayEvents;
check('native original callbacks execute inside configure guard',replayEvents.length>0&&replayEvents.every(e=>e.guard));inspectHosts(`subgraph reload ${round}`);
}
report.status='passed';report.lifecycle={configured,serialized};report.original=original;
}catch(error){report.status='failed';report.error=String(error);report.stack=error.stack;report.failedNativeNodes=(globalThis.__detachedSmartPipeApp?.graph?._nodes||[]).map(n=>({id:n.id,type:n.type,outputs:n.outputs?.map(p=>({name:p.name,id:p.bvSlotId,links:p.links,ctor:p.constructor.name}))}));}
result.textContent=JSON.stringify(report,null,2);globalThis.__nativeSmartPipeResult=report;
};
document.querySelector('#run').disabled=false;result.textContent='Ready';
</script>
+80
View File
@@ -0,0 +1,80 @@
<!doctype html><meta charset="utf-8"><title>Native subgraph width isolation</title><button id="run" disabled>Run width isolation</button><pre id="result">Loading</pre>
<script type="module">
const result=document.querySelector('#result');
document.querySelector('#run').onclick=async()=>{
const report={status:'running',cases:[],detached:true};
try{
const vue=await import('/native/assets/vendor-vue-core-BZZQdWHo.js');vue.g({render(){return null}}).use(vue.l());
const native=await import('/native/assets/settingStore-CwkLtSKP.js');
const LGraph=Object.values(native).find(v=>typeof v==='function'&&v.name==='LGraph');
const LGraphNode=Object.values(native).find(v=>typeof v==='function'&&v.name==='LGraphNode');
const LiteGraph=Object.values(native).find(v=>v&&typeof v.registerNodeType==='function'&&typeof v.createNode==='function');
const SubgraphNode=Object.values(native).find(v=>typeof v==='function'&&v.prototype?.isSubgraphNode&&v.prototype.isSubgraphNode());
const createGraph=()=>{const g=new LGraph();g.events.addEventListener('subgraph-created',({detail:{subgraph:sub}})=>{class Bound extends SubgraphNode{constructor(){super(g,sub,{id:-1,type:sub.id,pos:[0,0],size:[200,100],flags:{},order:0,mode:0});}}LiteGraph.registerNodeType(sub.id,Bound);});return g;};
const settle=()=>new Promise(r=>setTimeout(()=>requestAnimationFrame(()=>requestAnimationFrame(r)),600));
class Plain extends LGraphNode{constructor(){super('Native control');this.addWidget('number','value',1,()=>{});}}
LiteGraph.registerNodeType('BV Test Width Native',Plain);
for(const count of [1,5]){
let graph=createGraph();const nodes=[];
for(let i=0;i<count;i++){const n=LiteGraph.createNode('BV Test Width Native');n.pos=[i*220,0];graph.add(n);nodes.push(n);}
const {node:host}=graph.convertToSubgraph(new Set(nodes));await settle();
host.setSize([500,Math.max(200,host.size[1])]);await settle();
const c={variant:`no BV extensions, ${count} native nodes`,promoted:false,before:[...host.size],rounds:[]};report.cases.push(c);
let id=host.id;
for(let round=0;round<3;round++){
const saved=JSON.parse(JSON.stringify(graph.serialize()));const stored=saved.nodes.find(n=>String(n.id)===String(id));
graph=createGraph();graph.configure(saved);const restored=graph.getNodeById(id);
const observation={saved:[...stored.size],immediate:[...restored.size]};await settle();observation.settled=[...restored.size];observation.preserved=observation.settled[0]===500;c.rounds.push(observation);
}
}
// Isolate the actual BV projection against native sizing. Only the host's
// resolved-connection/widget lookup seam is supplied explicitly here.
globalThis.__widthApp={registerExtension(){}};
const url=new URL('/repo/js/bv_seed.js',location.href);let text=await(await fetch(url,{cache:"no-store"})).text();
text=text.replace('import { app } from "../../scripts/app.js";','const app=globalThis.__widthApp;').replace(/from "(\.\/[^\"]+)"/g,(_,p)=>`from ${JSON.stringify(new URL(p,url).href)}`);
report.seedSourceUsesSharedResize=text.includes('resizeNodeToContent');
text+='\nexport {relayout};';const blob=URL.createObjectURL(new Blob([text],{type:'text/javascript'}));const seed=await import(blob);URL.revokeObjectURL(blob);
let graph=createGraph();const n=LiteGraph.createNode('BV Test Width Native');graph.add(n);const {node:host}=graph.convertToSubgraph(new Set([n]));await settle();
host.setSize([500,200]);const plainBefore=[...host.size];seed.relayout(host);await settle();
report.cases.push({variant:'actual BV Seed relayout on native host',before:plainBefore,rounds:[{settled:[...host.size],preserved:host.size[0]===500}],adapterBoundary:'no promoted widget needed for direct layout isolation'});
const inner=new LGraphNode('Seed');inner.type='BV Seed';inner.properties={};inner.addInput('seed_bv','INT');
const promoted=host.addWidget('number','seed_bv',1,()=>{});host.addInput('seed','INT');
host.resolveSubgraphInputLinks=()=>[{inputNode:inner,input:inner.inputs[0]}];host.getWidgetFromSlot=()=>promoted;
host.setSize([500,300]);const before=[...host.size];const matched=seed.projectSeedControls(host);await settle();
report.cases.push({variant:'actual projectSeedControls native host/widget sizing',before,matched,rounds:[{settled:[...host.size],preserved:host.size[0]===500}],adapterBoundary:'connection resolver and promoted-widget lookup explicitly bound; not full native promotion lifecycle'});
host.setSize([320,300]);seed.relayout(host);await settle();
report.cases.push({variant:'native user deliberately narrows 500 to 320 then relayout',before:[320,300],rounds:[{settled:[...host.size],preserved:host.size[0]===320}]});
const projection=await import('/repo/js/bv_subgraph_ui_projection.js?probe='+Date.now());
const loadPatch=async(file,name)=>{const u=new URL('/repo/js/'+file,location.href);let t=await(await fetch(u,{cache:"no-store"})).text();t=t.replace('import { app } from "../../scripts/app.js";','const app=globalThis.__widthApp;');t+='\nexport {'+name+'};';const b=URL.createObjectURL(new Blob([t],{type:'text/javascript'}));try{return (await import(b))[name]}finally{URL.revokeObjectURL(b)}};
const patchHeading=await loadPatch('bv_subgraph_heading.js','patchHeading'),patchDivider=await loadPatch('bv_subgraph_divider.js','patchDivider');
for(const variant of ['heading','dividers','heading+dividers','heading+dividers+seed']){
const g=createGraph(),plain=LiteGraph.createNode('BV Test Width Native');g.add(plain);const h=g.convertToSubgraph(new Set([plain])).node;
const mappings=[];
const addPresentation=(kind)=>{
const sn=new LGraphNode(kind);sn.type=kind;sn.properties={};
const heading=kind==='BV Subgraph Heading';
if(heading){sn.addWidget('text','value','Dashboard',()=>{});sn.addWidget('number','font_size',20,()=>{});sn.addWidget('toggle','show_divider',false,()=>{});patchHeading(sn);}
else{sn.addWidget('number','thickness',2,()=>{});sn.addWidget('number','padding',10,()=>{});sn.addWidget('number','alpha',.35,()=>{});patchDivider(sn);}
const input=sn.addInput(heading?'value':'thickness',heading?'STRING':'INT');const hi=h.addInput('presentation_'+mappings.length,input.type);const w=h.addWidget(heading?'text':'number','presentation_'+mappings.length,heading?'Dashboard':2,()=>{});
mappings.push({inputNode:sn,input,hostInput:hi,widget:w});
};
if(variant.includes('heading'))addPresentation('BV Subgraph Heading');if(variant.includes('dividers'))for(let i=0;i<3;i++)addPresentation('BV Subgraph Divider');
if(variant.includes('seed')){const sn=new LGraphNode('Seed');sn.type='BV Seed';sn.properties={};const input=sn.addInput('seed_bv','INT'),hi=h.addInput('seed','INT'),w=h.addWidget('number','seed_bv',1,()=>{});mappings.push({inputNode:sn,input,hostInput:hi,widget:w});}
h.resolveSubgraphInputLinks=index=>[mappings[index]];h.getWidgetFromSlot=input=>mappings.find(m=>m.hostInput===input)?.widget;
h.setSize([500,400]);const before=[...h.size];const projected=projection.projectSubgraphUIPresentation(h);const afterPresentation=[...h.size];const seedCount=variant.includes('seed')?seed.projectSeedControls(h):0;await settle();
report.cases.push({variant:'actual projection fallback '+variant,before,projected,seedCount,afterPresentation,rounds:[{settled:[...h.size],preserved:h.size[0]===500}],adapterBoundary:'real source patches/native host and widgets; explicit resolver mapping; no TS presentation bridge loaded'});
}
let narrowGraph=createGraph();const narrowInner=LiteGraph.createNode('BV Test Width Native');narrowGraph.add(narrowInner);const narrowHost=narrowGraph.convertToSubgraph(new Set([narrowInner])).node;
narrowHost.setSize([500,300]);narrowHost.setSize([320,300]);const narrowId=narrowHost.id,narrowSaved=JSON.parse(JSON.stringify(narrowGraph.serialize()));
narrowGraph=createGraph();narrowGraph.configure(narrowSaved);const narrowRestored=narrowGraph.getNodeById(narrowId);const narrowImmediate=[...narrowRestored.size];seed.relayout(narrowRestored);await settle();
report.cases.push({variant:'native 500 to 320, whole graph reload, actual Seed relayout',before:[320,300],rounds:[{saved:narrowSaved.nodes.find(n=>String(n.id)===String(narrowId)).size,immediate:narrowImmediate,settled:[...narrowRestored.size],preserved:narrowRestored.size[0]===320}],adapterBoundary:'native whole graph lifecycle plus actual relayout; no promoted lookup override'});
const hiddenGraph=createGraph(),hiddenInner=LiteGraph.createNode('BV Test Width Native');hiddenGraph.add(hiddenInner);const hiddenHost=hiddenGraph.convertToSubgraph(new Set([hiddenInner])).node;
hiddenHost.setSize([500,250]);const publicMeasured=[...hiddenHost.computeSize()];const hiddenPort=hiddenHost.addOutput('hidden_provider_'.repeat(60),'STRING');hiddenPort.hidden=true;
const withHiddenMeasured=[...hiddenHost.computeSize()];seed.relayout(hiddenHost);
report.hiddenProviderProbe={publicMeasured,withHiddenMeasured,after:[...hiddenHost.size],widthPreserved:hiddenHost.size[0]===500,boundary:'one real native hidden output, no visible content added; diagnostic only'};
report.status=report.cases.every(c=>c.rounds.every(r=>r.preserved))?'passed':'reproduced';
}catch(error){report.status='failed';report.error=String(error);report.stack=error.stack;}
result.textContent=JSON.stringify(report,null,2);globalThis.__nativeWidthResult=report;
};
document.querySelector('#run').disabled=false;result.textContent='Ready';
</script>
+39 -10
View File
@@ -11,7 +11,7 @@ import { configureNodes2NodePresentation, installNodes2NodePresentation, project
import { installLutNodePresentation } from "../ui/src/regional/lutNodePresentation.ts";
import { installNodePresentationLifecycle } from "../ui/src/regional/nodePresentationLifecycle.ts";
import { lutLibrary } from "../ui/src/regional/lutLibrary.ts";
import { installProjectedPortInteraction } from "../ui/src/regional/projectedPortInteraction.ts";
import { installProjectedPortInteraction, installProjectedPortCanvasInteraction } from "../ui/src/regional/projectedPortInteraction.ts";
import { configurePresentationSizeLifecycle, installPresentationSizeLifecycle, isPresentationUserResizing, presentationSize, removePresentationSizeLifecycle, setAutomaticPresentationSize } from "../ui/src/regional/presentationSize.ts";
const indexSource=readFileSync(new URL("../ui/src/index.tsx",import.meta.url),"utf8");
@@ -497,13 +497,13 @@ test("Detector Registry hides external reserves only in Ghost and always hides p
}
});
test("Regional Native Conditioning Classic reserves both legacy rows without resizing",()=>{
test("Regional Native Conditioning Classic hides unused legacy rows and preserves debug access",()=>{
const sizes=[];
const node={size:[300,260],properties:{},inputs:nativeConditioningInventory.ports.filter(port=>port.direction==="input").map(port=>({name:port.name,link:null})),outputs:nativeConditioningInventory.ports.filter(port=>port.direction==="output").map(port=>({name:port.name,links:null})),widgets:nativeConditioningInventory.widgets.map(({name})=>({name,computeSize:()=>[0,20]})),computeSize(){return[280,180]},setSize(size){sizes.push([...size]);this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:false});
assert.deepEqual(node.size,[300,180]);
assert.equal(sizes.length,1);
for(const name of["lora_registry","lora_bindings"]){const port=node.inputs.find(item=>item.name===name);assert.equal(port.hidden,false);assert.equal(port.__bvM0VisualHidden,true)}
for(const name of["lora_registry","lora_bindings"]){const port=node.inputs.find(item=>item.name===name);assert.equal(port.hidden,true);assert.equal(port.__bvM0VisualHidden,true)}
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:true});
for(const name of["lora_registry","lora_bindings"])assert.equal(node.inputs.find(item=>item.name===name).__bvM0VisualHidden,false);
applyClassicNodePresentation(node,"BV Regional Native Conditioning",{legacyDebug:false});
@@ -654,9 +654,9 @@ function regionalPromptNode(){
test("Regional Prompt Classic reconciliation is reversible and publishes one final height",()=>{
const{node,sizes}=regionalPromptNode();
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
assert.deepEqual(node.size,[300,120]);
assert.deepEqual(node.size,[300,100]);
assert.equal(sizes.length,1);
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").hidden,false);
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").hidden,true);
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").__bvM0VisualHidden,true);
assert.equal(node.inputs[0].hidden,true);
assert.equal(node.inputs[1].hidden,false);
@@ -665,17 +665,17 @@ test("Regional Prompt Classic reconciliation is reversible and publishes one fin
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:true});
assert.deepEqual(node.size,[300,120]);
assert.equal(sizes.length,1);
assert.equal(sizes.length,2);
assert.equal(node.outputs.find(port=>port.name==="lora_bindings").hidden,false);
assert.equal(node.inputs[0].hidden,true);
assert.equal(node.inputs[1].hidden,false);
assert.equal(node.inputs[1].link,81);
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
assert.deepEqual(node.size,[300,120]);
assert.equal(sizes.length,1);
assert.deepEqual(node.size,[300,100]);
assert.equal(sizes.length,3);
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
assert.equal(sizes.length,1);
assert.equal(sizes.length,3);
assert.equal(node.properties.bvPresentationUserHeight,undefined);
});
@@ -685,7 +685,7 @@ test("Regional Prompt publishes a semantic Nodes 2 height even when host compute
{name:"open_regional_editor",computeSize:()=>[0,20]},{name:"quick_edit_regional_prompts",computeSize:()=>[0,20]},
],computeSize(){return[420,330]},setSize(size){this.size=[...size]},setDirtyCanvas(){},graph:{setDirtyCanvas(){}}};
applyClassicNodePresentation(node,"BV Regional Prompt",{legacyDebug:false});
assert.equal(node.__bvPresentationAutoHeight,120);
assert.equal(node.__bvPresentationAutoHeight,100);
assert.ok(node.widgets.slice(0,5).every(widget=>widget.options.hidden===true));
});
@@ -1684,3 +1684,32 @@ test("removing presentation cancels pending projected-port layout callbacks",asy
assert.equal(node.__bvRefreshProviderAnchors,undefined);
assert.equal(Object.hasOwn(provider,"pos"),false);
});
test('internal state widget inputs never become visible public anchors',()=>{
for(const surface of ['classic','nodes2','ghost']){
const result=resolveNodePresentation('BV Regional Prompt',{ports:[{direction:'input',name:'regional_json',type:'STRING'},{direction:'input',name:'canvas_image',type:'IMAGE'},{direction:'output',name:'regional_json',type:'STRING'}],widgets:[{name:'regional_json'}]},{surface,legacyDebug:true});
assert.equal(result.ports[0].role,'internalState');assert.equal(result.ports[0].visible,false);assert.equal(result.ports[1].visible,true);assert.equal(result.ports[2].visible,true);
}
});
test('internal state input hitboxes are gated even when provider predicate does not own them',()=>{
const slot={__bvPresentationRole:'internalState'},node={getSlotInPosition(){return{input:slot}}};
installProjectedPortInteraction(node,()=>false);assert.equal(node.getSlotInPosition(10,10),undefined);
slot.__bvPresentationRole='public';assert.equal(node.getSlotInPosition(10,10).input,slot);
});
test('overlapping internal inputs are transparent to native picking and restore geometry even on failure',()=>{
const internal={__bvPresentationRole:'internalState',boundingRect:[840,190,20,20]},image={name:'canvas_image',boundingRect:[840,194,20,20]};
const inputs=[internal,image];
const node={inputs,outputs:[],getInputPos(i){return[850,i?204:200]},getSlotInPosition(x,y){for(const [i,input]of this.inputs.entries()){const p=this.getInputPos(i);if(Math.abs(x-p[0])<=10&&Math.abs(y-p[1])<=10)return{input,slot:i}}}};
const getPos=node.getInputPos;
installProjectedPortInteraction(node,()=>false);
assert.equal(node.getSlotInPosition(850,200).input,image);
assert.equal(node.getSlotInPosition(850,200).slot,1);
assert.equal(node.getSlotInPosition(850,191),undefined);
let selected;
const canvas={pointer:{},_processNodeClick(e,_ctrl,node){selected=node.inputs.find(s=>e.canvasX>=s.boundingRect[0]&&e.canvasX<=s.boundingRect[0]+20&&e.canvasY>=s.boundingRect[1]&&e.canvasY<=s.boundingRect[1]+20);if(e.fail)throw Error('native failure')}};
installProjectedPortCanvasInteraction(canvas);
canvas._processNodeClick({canvasX:850,canvasY:200},false,node);assert.equal(selected,image);
assert.throws(()=>canvas._processNodeClick({canvasX:850,canvasY:200,fail:true},false,node),/native failure/);
assert.equal(node.inputs,inputs);assert.equal(node.getInputPos,getPos);assert.deepEqual(internal.boundingRect,[840,190,20,20]);assert.deepEqual(image.boundingRect,[840,194,20,20]);
});
+13
View File
@@ -138,3 +138,16 @@ test("central React node widget host caps intrinsic height against both pixels a
assert.match(content.className,/bv-react-node-widget-scroll/);
assert.equal(options.margin,10);assert.equal(options.getHeight,undefined);assert.equal(content.style.maxHeight,"min(420px, 60vh, 100%)");assert.equal(content.style.overflowY,"auto");
});
test("opt-in content growth caps intrinsic height and yields to explicit user height",()=>{
let resize,options;const content={className:"",style:{}};
configureReactNodeWidgetHost({createHost:()=>({className:"",dataset:{},remove(){}}),createContentHost:()=>content,createRoot:()=>({render(){},unmount(){}}),schedule:action=>action(),applyPresentation:()=>{},viewportHeight:()=>1000,observeHost:(_host,measure)=>{resize=measure;return()=>{}}});
function NodeType(){}
installReactNodeWidgetHost(NodeType,"BV LoRA Registry",{id:"grow",name:"grow",minHeight:72,maxHeight:340,growWithContent:true,render:()=>null});
const node=new NodeType();node.addDOMWidget=(_name,_type,_host,value)=>{options=value;return{}};node.onNodeCreated();
resize(180);assert.equal(options.getMinHeight(),180);
resize(700);assert.equal(options.getMinHeight(),340);
node.properties={bvPresentationSizeVersion:1,bvPresentationUserHeight:300};
resize(800);assert.equal(options.getMinHeight(),72);assert.equal(options.getMaxHeight(),Infinity);assert.equal(content.style.maxHeight,"100%");
node.onRemoved();
});
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { reconcileReferenceRegistry, parseReferenceConfig, installReferenceRegistryLifecycle, freshenDuplicateReferenceRegistry } from '../ui/src/regional/referenceRegistryLifecycle.ts';
import { resolveNodePresentation } from '../ui/src/regional/nodePresentation.ts';
import { suppressInitialProjectedProviderDefinitions, reconcileDeferredPublicInputs } from '../ui/src/regional/portProjection.ts';
import { installM0CanvasVisibility, projectedProviderLink } from '../ui/src/regional/m0VisualProjection.ts';
import { installDgAnchorInteractionGuard } from '../ui/src/regional/dgRouting.ts';
const node=()=>({type:'BV Reference Registry',widgets:[{name:'config_json',value:''}],inputs:[0,1,2].map(i=>({name:`media.media${i}`,link:10+i}))});
const config=n=>parseReferenceConfig(n.widgets[0].value);
test('logical places survive source swaps and retain vacant slots',()=>{
const n=node(),first=reconcileReferenceRegistry(n);
n.inputs[1].link=null;
const second=reconcileReferenceRegistry(n);
assert.deepEqual(second.entries,[first.entries[0],first.entries[2]]);
assert.deepEqual(second.places,first.places);
n.inputs[1].link=99;
assert.deepEqual(reconcileReferenceRegistry(n).entries,first.entries);
n.inputs.push({name:'media.media3',link:100});
const third=reconcileReferenceRegistry(n);
assert.deepEqual(third.entries.slice(0,3),first.entries);
assert.ok(!first.places.some(place=>place.id===third.entries[3].id));
});
test('input object replacement preserves place identity independent of native link numbers',()=>{
const n=node(),first=reconcileReferenceRegistry(n);
n.inputs=[{name:'media.media0',link:12},{name:'media.media1',link:10},{name:'media.media2',link:11}];
assert.deepEqual(reconcileReferenceRegistry(n).entries,first.entries);
});
test('save/reload restores IDs even when native link numbers change',()=>{
const n=node();reconcileReferenceRegistry(n);
const restored=JSON.parse(JSON.stringify(n));restored.inputs.forEach(s=>s.link+=100);
reconcileReferenceRegistry(restored,true);
assert.deepEqual(config(restored),config(n));
});
test('disconnect/reconnect keeps its place; configure Undo restores its snapshot',()=>{
const n=node(),before=reconcileReferenceRegistry(n),snapshot=JSON.parse(JSON.stringify(n));
n.inputs[2].link=null;reconcileReferenceRegistry(n);
n.inputs[2].link=123;const after=reconcileReferenceRegistry(n);
assert.deepEqual(after.entries.slice(0,2),before.entries.slice(0,2));
assert.equal(after.entries[2].id,before.entries[2].id);
const undo=JSON.parse(JSON.stringify(snapshot));reconcileReferenceRegistry(undo,true);
assert.deepEqual(config(undo),before);
});
test('clone freshens collector identity across concrete subgraphs, keeps scoped entry IDs',()=>{
const source=node();reconcileReferenceRegistry(source);
const clone=JSON.parse(JSON.stringify(source));
const inner={_nodes:[]},root={_nodes:[{subgraph:inner},source]};source.graph=root;
freshenDuplicateReferenceRegistry(source);
inner._nodes.push(clone);clone.graph={rootGraph:root};
freshenDuplicateReferenceRegistry(clone);
assert.notEqual(config(clone).collector_id,config(source).collector_id);
assert.deepEqual(config(clone).entries,config(source).entries);
const before=config(source);freshenDuplicateReferenceRegistry(source);assert.deepEqual(config(source),before);
});
test('configure and immediate prompt serialization do not replace persisted identities',async()=>{
class Native {constructor(){Object.assign(this,node())}onConfigure(){}onNodeCreated(){}}
installReferenceRegistryLifecycle(Native);
const n=new Native();n.onNodeCreated();await Promise.resolve();
const before=config(n);
n.onConfigure();
const serialized=n.widgets[0].serializeValue();
assert.deepEqual(JSON.parse(serialized),before);
await Promise.resolve();assert.deepEqual(config(n),before);
});
test('history serialization never commits transient disconnected state',async()=>{
class Native {constructor(){Object.assign(this,node())}onNodeCreated(){}}
installReferenceRegistryLifecycle(Native);
const n=new Native();n.onNodeCreated();await Promise.resolve();
const before=config(n);
n.inputs[1].link=null;
const snapshot={widgets_values:[],widgets_values_named:{}};
n.onSerialize(snapshot);
assert.deepEqual(JSON.parse(n.widgets[0].serializeValue()),before);
assert.deepEqual(JSON.parse(snapshot.widgets_values[0]),before);
assert.deepEqual(JSON.parse(snapshot.widgets_values_named.config_json),before);
assert.deepEqual(config(n),before);
});
test('native configure connection callbacks cannot open an identity transaction',async()=>{
const raf=globalThis.requestAnimationFrame,cancel=globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame=()=>1;globalThis.cancelAnimationFrame=()=>{};
try{
let begins=0;
class Native{constructor(){Object.assign(this,node());this.graph={beforeChange(){begins++},afterChange(){}}}onNodeCreated(){}configure(snapshot){this.onConnectionsChange(1,0);this.widgets[0].value=snapshot;this.onConfigure({widgets_values:[snapshot]})}}
installReferenceRegistryLifecycle(Native);
const n=new Native();n.onNodeCreated();await Promise.resolve();const before=n.widgets[0].value;
n.onConnectionsChange(1,0);assert.equal(begins,1);
n.configure(before);await Promise.resolve();
assert.equal(begins,1);assert.equal(n.widgets[0].value,before);
}finally{globalThis.requestAnimationFrame=raf;globalThis.cancelAnimationFrame=cancel}
});
test('disconnect identity update is one balanced history transaction across registries',async()=>{
const raf=globalThis.requestAnimationFrame,cancel=globalThis.cancelAnimationFrame,frames=new Map();let serial=0,depth=0;
globalThis.requestAnimationFrame=callback=>{frames.set(++serial,callback);return serial};
globalThis.cancelAnimationFrame=id=>frames.delete(id);
try{
const snapshots=[],graph={beforeChange(){depth++},afterChange(){if(--depth===0)snapshots.push(nodes.map(config))}};
class Native{constructor(){Object.assign(this,node());this.graph=graph}onNodeCreated(){}}
installReferenceRegistryLifecycle(Native);
const nodes=[new Native(),new Native()];nodes.forEach(n=>n.onNodeCreated());await Promise.resolve();
const before=nodes.map(config);
graph.beforeChange();
for(const n of nodes){
n.inputs[1].link=null;n.onConnectionsChange(1,1,false);
}
graph.afterChange();assert.equal(snapshots.length,0);
await Promise.resolve();
for(const [id,callback] of [...frames]){frames.delete(id);callback()}
assert.equal(depth,0);assert.equal(snapshots.length,1);
snapshots[0].forEach((value,index)=>assert.deepEqual(value.entries,[before[index].entries[0],before[index].entries[2]]));
}finally{globalThis.requestAnimationFrame=raf;globalThis.cancelAnimationFrame=cancel}
});
test('pending transaction closes exactly once on configure, removal and callback failure',async()=>{
const raf=globalThis.requestAnimationFrame,cancel=globalThis.cancelAnimationFrame,frames=new Map();let serial=0;
globalThis.requestAnimationFrame=callback=>{frames.set(++serial,callback);return serial};
globalThis.cancelAnimationFrame=id=>frames.delete(id);
try{
for(const termination of ['onConfigure','onRemoved','throw']){
let depth=0,closed=0;
class Native{constructor(){Object.assign(this,node());this.graph={beforeChange(){depth++},afterChange(){depth--;closed++}}}onNodeCreated(){}onConnectionsChange(){if(termination==='throw')throw Error('native failure')}}
installReferenceRegistryLifecycle(Native);
const n=new Native();n.onNodeCreated();await Promise.resolve();
if(termination==='throw')assert.throws(()=>n.onConnectionsChange(1,1),/native failure/);
else{n.onConnectionsChange(1,1);await Promise.resolve();n[termination]()}
await Promise.resolve();
assert.equal(depth,0);assert.equal(closed,1);assert.equal(frames.size,0);
}
}finally{globalThis.requestAnimationFrame=raf;globalThis.cancelAnimationFrame=cancel}
});
test('restore uses captured serialized identity even when native widget hydration writes stale value',async()=>{
class Native{constructor(){Object.assign(this,node())}onNodeCreated(){}}
installReferenceRegistryLifecycle(Native);
const n=new Native();n.onNodeCreated();await Promise.resolve();
const before=n.widgets[0].value;
n.onConfigure({widgets_values:[before]});
const stale=JSON.parse(before);stale.entries.splice(1,1);stale.entries[1].slot='media1';
n.widgets[0].value=JSON.stringify(stale);
await Promise.resolve();assert.equal(n.widgets[0].value,before);
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyInteractiveFanIn,removeInteractiveFanIn} from '../ui/src/regional/interactiveFanIn.ts';
import {applyClassicNodePresentation} from '../ui/src/regional/classicNodePresentation.ts';
import {resolveNodePresentation} from '../ui/src/regional/nodePresentation.ts';
import {installNodePresentationLifecycle} from '../ui/src/regional/nodePresentationLifecycle.ts';
import {registryChannelFamily,registryKindAllowed,uniqueWorkflowRegistry} from '../ui/src/regional/registryDgFamilies.ts';
import {prepareDgClipboard} from '../ui/src/regional/dgRouting.ts';
import '../ui/src/regional/loraRegistryDgAdapter.ts';
import {parseReferenceSelection} from '../ui/src/regional/referenceRegistryGraph.ts';
test('one fan-in is visible on every surface and targets only free canonical inputs',()=>{
const inputs=[0,1,2].map(index=>({name:`media.media${index}`,type:'IMAGE,AUDIO,VIDEO',link:index===2?null:index+1}));
const original=()=>undefined,n={type:'BV Reference Registry',inputs,outputs:[],widgets:[],pos:[50,60],getInputOnPos:original,getConnectionPos(){return[1,2]}};
for(const surface of ['classic','ghost','nodes2']){
const plan=resolveNodePresentation(n.type,{ports:inputs.map(slot=>({...slot,direction:'input',connected:slot.link!=null})),widgets:[]},{surface,legacyDebug:false});
assert.deepEqual(plan.ports.filter(slot=>slot.visible).map(slot=>slot.name),['media.media2']);
}
applyClassicNodePresentation(n,n.type);
assert.equal(n.getInputOnPos([50,70]),inputs[2]);
assert.deepEqual(inputs.map((_,index)=>n.getConnectionPos(true,index)),[[50,70],[50,70],[50,70]]);
assert.equal(n.onConnectInput(0),false);assert.equal(n.onConnectInput(2),true);
inputs[2].link=3;applyInteractiveFanIn(n);assert.equal(n.getInputOnPos([50,70]),undefined);
n.flags={collapsed:true};assert.deepEqual(n.getConnectionPos(true,1),[50,45]);
removeInteractiveFanIn(n);assert.equal(n.getInputOnPos,original);assert.ok(inputs.every(slot=>!Object.hasOwn(slot,'pos')));
});
test('explicit socketless schema survives native STRING widget creation',()=>{
class Node{addWidget(type,name){return{name,options:{}}}addDOMWidget(name){return{name,options:{}}}}
installNodePresentationLifecycle(Node,{name:'BV Reference Registry',input:{required:{config_json:['STRING',{socketless:true}],other:['STRING',{}]}}});
const n=new Node();assert.equal(n.addDOMWidget('config_json').options.socketless,true);
assert.equal(n.addWidget('text','config_json').options.socketless,true);
assert.equal(n.addWidget('text','other').options.socketless,undefined);
});
test('reference family is isolated and uses collector IDs across concrete scopes',()=>{
assert.equal(registryChannelFamily({type:'BV Regional Prompt'},'reference_resource_provider_20'),'reference');
assert.equal(registryChannelFamily({type:'BV Regional Prompt'},'reference_resource_provider_21'),null);
assert.equal(registryKindAllowed('reference','reference'),true);
for(const kind of ['lora','lut','detector',null])assert.equal(registryKindAllowed('reference',kind),false);
const id=crypto.randomUUID(),source={type:'BV Reference Registry',widgets:[{name:'config_json',value:JSON.stringify({collector_id:id})}]},root={_nodes:[source]},child={rootGraph:root,_nodes:[]};
root._nodes.push({subgraph:child});const target={__bvConcreteGraph:child};
assert.equal(uniqueWorkflowRegistry(target,'reference',id),source);
child._nodes.push(structuredClone(source));assert.equal(uniqueWorkflowRegistry(target,'reference',id),null);
assert.throws(()=>parseReferenceSelection({version:1,collector_ids:[id,id]}));
assert.throws(()=>parseReferenceSelection({version:1,collector_ids:Array.from({length:21},()=>crypto.randomUUID())}));
});
test('registry and RP clipboard remap domain and DG transport without changing other families',()=>{
const id=crypto.randomUUID(),resource=crypto.randomUUID(),config={schema:'bv.reference_registry_config',version:1,collector_id:id,entries:[{id:resource,slot:'media0'}]};
const source={type:'BV Reference Registry',properties:{bvDgSenderId:'source',bvRegistryDgSource:true},widgets_values:[JSON.stringify(config)]};
const selection=JSON.stringify({version:1,collector_ids:[id]}),old=['document','bindings','lora','detailer','lut'];
const prompt={type:'BV Regional Prompt',properties:{bvRegistryDgFamilies:['reference'],bvRegistryDgSelections:{reference_resource_provider_1:'source'},bvRegistryDgSourceKinds:{reference_resource_provider_1:'reference'}},widgets_values:[...old,selection]};
const copy=prepareDgClipboard({nodes:[source,prompt]}),next=JSON.parse(copy.nodes[0].widgets_values[0]);
assert.notEqual(next.collector_id,id);assert.equal(next.entries[0].id,resource);
assert.deepEqual(JSON.parse(copy.nodes[1].widgets_values[5]).collector_ids,[next.collector_id]);
assert.deepEqual(copy.nodes[1].widgets_values.slice(0,5),old);
assert.equal(copy.nodes[1].properties.bvRegistryDgSelections.reference_resource_provider_1,copy.nodes[0].properties.bvDgSenderId);
assert.equal(prepareDgClipboard({nodes:[prompt]}).nodes[0].widgets_values[5],selection);
assert.equal(source.widgets_values[0],JSON.stringify(config));
});
test('authoring-only copied references follow their copied registry without DG selection',()=>{
const id=crypto.randomUUID(),resource=crypto.randomUUID(),place={id:resource,slot:'media0'};
const registry={type:'BV Reference Registry',properties:{},widgets_values:[JSON.stringify({schema:'bv.reference_registry_config',version:1,collector_id:id,entries:[place],places:[place]})]};
const document={prompts:{global:{positive_source:'@Image 1',references:{positive:[{start:0,end:8,label:'@Image 1',collector_id:id,resource_id:resource}]}}},regions:[]};
const prompt={type:'BV Regional Prompt',properties:{},widgets_values:[JSON.stringify(document)]};
const copy=prepareDgClipboard({nodes:[registry,prompt]});
const next=JSON.parse(copy.nodes[0].widgets_values[0]);
assert.notEqual(next.collector_id,id);
assert.deepEqual(next.places,[place]);
assert.equal(JSON.parse(copy.nodes[1].widgets_values[0]).prompts.global.references.positive[0].collector_id,next.collector_id);
assert.equal(JSON.parse(prepareDgClipboard({nodes:[prompt]}).nodes[0].widgets_values[0]).prompts.global.references.positive[0].collector_id,id);
});
+10 -10
View File
@@ -42,36 +42,36 @@ test("canvas selector renders Last Sent Image and a fail-closed unavailable sele
const options=regionalCanvasImageOptions(images,missing,apiURL);
assert.match(normal,/Last Sent Image/);assert.match(normal,/src="\/api\/view\?filename=a.png/);
assert.match(unavailable,/Source unavailable/);assert.doesNotMatch(unavailable,/<img/);
assert.equal(options[0].src,"/api/view?filename=a.png&type=temp&subfolder=");
assert.equal(options[1].disabled,true);assert.equal(options[1].src,undefined);
assert.equal(options[2].thumbnail,"/api/view?filename=a.png&type=temp&subfolder=");
assert.equal(options[1].src,"/api/view?filename=a.png&type=temp&subfolder=");
assert.equal(options[2].disabled,true);assert.equal(options[2].src,undefined);
assert.equal(options[3].thumbnail,"/api/view?filename=a.png&type=temp&subfolder=");
});
test("Last Sent Image remains selectable without a published image",()=>{
const options=regionalCanvasImageOptions([],LAST_SENT_IMAGE_SELECTION,path=>path);
assert.deepEqual(options,[{id:LAST_SENT_IMAGE_SELECTION,name:"Last Sent Image"}]);
assert.deepEqual(options,[{id:"connected-input-image",name:"Input image · Unavailable"},{id:LAST_SENT_IMAGE_SELECTION,name:"Last Sent Image"}]);
const markup=renderToStaticMarkup(React.createElement(RegionalCanvasImageSelect,{images:[],selection:LAST_SENT_IMAGE_SELECTION,onSelection(){},apiURL:path=>path}));
assert.match(markup,/Last Sent Image/);assert.match(markup,/bv-image-picker-placeholder/);assert.doesNotMatch(markup,/<img/);
});
test("canvas selector hides technical filenames and labels multi-image batches semantically",()=>{
const scope={};let catalog=emptyRegionalCanvasImageCatalog();catalog=ingestRegionalCanvasImagePublication(catalog,scope,publication("doc","7","regional-image-send","batch",["bv_regional_canvas_temp_first.png","bv_regional_canvas_temp_second.png"]),"7");
const images=regionalCanvasImagesForDocument(catalog,scope,"doc"),options=regionalCanvasImageOptions(images,images[1].id,path=>path).slice(1);
const images=regionalCanvasImagesForDocument(catalog,scope,"doc"),options=regionalCanvasImageOptions(images,images[1].id,path=>path).slice(2);
assert.deepEqual(options.map(option=>option.name),["Send #7 · Image 1","Send #7 · Image 2"]);
options.forEach((option,index)=>{assert.equal(Object.hasOwn(option,"meta"),false);assert.equal(option.id,images[index].id);assert.match(option.src,new RegExp(escapeRegExp(images[index].filename)));assert.equal(option.thumbnail,option.src)});
const markup=renderToStaticMarkup(React.createElement(RegionalCanvasImageSelect,{images,selection:images[1].id,onSelection(){},apiURL:path=>path}));
assert.match(markup,/Send #7 · Image 2/);assert.doesNotMatch(markup,/<small/);
});
test("canvas selector width is stable across option contents",()=>{
test("canvas selector has bounded flexible width and footer groups wrap in narrow panels",()=>{
const picker=ruleBody('.bv-canvas-image-select');
hasDeclaration(picker,"box-sizing","border-box");
hasDeclaration(picker,"display","flex");
hasDeclaration(picker,"align-items","center");
hasDeclaration(picker,"flex","0 0 min(300px,40vw)");
hasDeclaration(picker,"inline-size","min(300px,40vw)");
hasDeclaration(picker,"min-inline-size","min(220px,40vw)");
hasDeclaration(picker,"max-inline-size","min(300px,40vw)");
hasDeclaration(picker,"flex","1 1 160px");
hasDeclaration(picker,"min-inline-size","120px");
hasDeclaration(picker,"max-inline-size","300px");
hasDeclaration(ruleBody('.artboard-bottom-controls'),"flex-wrap","wrap");
const control=ruleBody('.bv-canvas-image-select .bv-image-picker-control');
hasDeclaration(control,"box-sizing","border-box");
hasDeclaration(control,"width","100%");
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {visibleSegmentCount,toggleSegment} from '../ui/src/ui/components/segmentedModel.ts';
import {referenceRequest,insertReference,updateMentions} from '../ui/src/completion/referenceMentions.ts';
import {applyInteractiveFanIn,removeInteractiveFanIn} from '../ui/src/regional/interactiveFanIn.ts';
test('overflow reserves trigger space, restores order and keeps independent toggles',()=>{
assert.equal(visibleSegmentCount([70,65,80],215,50),3);
assert.equal(visibleSegmentCount([70,65,80],150,50),1);
assert.equal(visibleSegmentCount([70,65,80],40,50),0);
assert.deepEqual(toggleSegment(['generation'],'detailer',true),['generation','detailer']);
assert.deepEqual(toggleSegment(['generation'],'generation',true),['generation']);
assert.deepEqual(toggleSegment(['lora','lut'],'lora'),['lut']);
});
test('references insert by explicit choice without comma rules or interpreting literal @',()=>{
const text='👩 woman @Im walks', request=referenceRequest(text,12);
assert.equal(request.term,'im');
const choice={collector_id:crypto.randomUUID(),resource_id:crypto.randomUUID(),label:'Image 1',origin:'Registry #4'};
const next=insertReference(text,request,choice,[]);
assert.equal(next.text,'👩 woman @Image 1 walks');
assert.equal(next.text.slice(next.mentions[0].start,next.mentions[0].end),'@Image 1');
assert.equal(referenceRequest('person@example.com',18),null);
assert.deepEqual(updateMentions('@artist','@artist ',[]),[]);
const shifted=updateMentions(next.text,'Hi '+next.text,next.mentions);
assert.equal(shifted[0].start,next.mentions[0].start+3);
assert.deepEqual(updateMentions(next.text,next.text.replace('@Image 1','@Image 2'),next.mentions),[]);
});
test('reference insertion cannot exceed the persisted mention limit',()=>{
const mention={start:0,end:8,label:'@Image 1',collector_id:crypto.randomUUID(),resource_id:crypto.randomUUID()};
const mentions=Array.from({length:100},(_,i)=>({...mention,start:i*9,end:i*9+8}));
const text=Array(100).fill('@Image 1').join(' ')+' @';
const next=insertReference(text,{start:text.length-1,end:text.length},{...mention,label:'Image 1',origin:'Registry'},mentions);
assert.equal(next.text,text);assert.equal(next.mentions.length,100);
});
test('native fan-in floor prevents deferred compaction even after a later native callback',()=>{
const group={min:0,max:100},node={inputs:[0,1,2].map(i=>({name:`media.media${i}`,__bvPresentationRole:'fanIn',link:null})),comfyDynamic:{autogrow:{media:group}}};
const callback=()=>{};node.onConnectionsChange=callback;applyInteractiveFanIn(node);assert.equal(node.onConnectionsChange,callback);assert.equal(group.min,4);assert.equal(group.max,100);
node.inputs.push({name:'media.media4',__bvPresentationRole:'fanIn',link:null});assert.equal(group.min,6);
removeInteractiveFanIn(node);assert.equal(group.min,0);
});
test('all 100 occupied places leave no connection target and preserve the native maximum',()=>{
const group={min:0,max:100,inputSpecs:[{isOptional:true}]};
const node={inputs:Array.from({length:100},(_,i)=>({name:`media.media${i}`,__bvPresentationRole:'fanIn',link:i+1})),comfyDynamic:{autogrow:{media:group}}};
applyInteractiveFanIn(node);assert.equal(group.min,101);assert.equal(group.max,100);assert.equal(node.onConnectInput(0),false);assert.equal(node.getInputOnPos([0,10]),undefined);removeInteractiveFanIn(node);assert.equal(group.min,0);
});
import {regionalActiveTools} from '../ui/src/regional/regionalToolState.ts';
test('shared editor tools infer legacy scopes and honor explicit off without modifying configuration',()=>{
const region={id:'r'},document={document_id:'doc',regions:[region]},lora={entries:[{targets:[{scope:'global'}]},{targets:[{scope:'region',document_id:'doc',region_id:'r'}]}]},bindings={regions:{}},lut={jobs:[{scope:'regional',region_ids:['r','other']}]};
const before=JSON.stringify({lora,bindings,lut});
assert.deepEqual(regionalActiveTools(document,null,lora,bindings,lut),['lora']);
assert.deepEqual(regionalActiveTools(document,region,lora,bindings,lut),['lora','lut']);
region.tool_settings={lora:false,lut:false};assert.deepEqual(regionalActiveTools(document,region,lora,bindings,lut),[]);
assert.deepEqual(regionalActiveTools(document,null,lora,bindings,lut),['lora']);
region.tool_settings={lora:true,lut:true};assert.deepEqual(regionalActiveTools(document,region,lora,bindings,lut),['lora','lut']);
assert.equal(JSON.stringify({lora,bindings,lut}),before);
});
+2 -2
View File
@@ -127,7 +127,7 @@ test("Regional Prompt easy mode renders every stack assigned to its scope",()=>{
test("Regional Prompt easy mode changes scope immediately when the selected region changes",()=>{
assert.match(regionalEditorSource,/regionalLoraScopeViewProps\(node,loraV3Config,setLoraV3Config\)/);
assert.doesNotMatch(pickerSource,/\[enabled,setEnabled\]=useState/);
assert.match(regionalEditorSource,/OptionalLoraV3ScopePicker key=\{selectedRegion\.id\}/);
assert.match(regionalEditorSource,/LoraV3ScopePicker key=\{selectedRegion\.id\}/);
const config=parseLoraV3Config({version:1,collector_id:"collector",entries:[{id:"left-entry",source:{kind:"external",resource_id:"skin"},targets:[{scope:"region",document_id:"doc",region_id:"left"}]}]});
const right=renderToStaticMarkup(React.createElement(OptionalLoraV3ScopePicker,{collectors,config,target:{scope:"region",document_id:"doc",region_id:"right"},resolved:true,onSelection(){},onAdd(){},onRemove(){},onClear(){}}));
assert.match(right,/LoRA disabled/);assert.doesNotMatch(right,/LoRA Stack 1/);
@@ -168,7 +168,7 @@ test("Quick Prompt Editor uses the canonical V3 LoRA scope instead of the legacy
assert.match(quickEditorSource,/hasRegionalLoraV3\(node\)/);
assert.match(quickEditorSource,/readNodeLoraV3Config\(node\)/);
assert.match(quickEditorSource,/LORA_V3_INVENTORY_CHANGED_EVENT/);
assert.match(quickEditorSource,/OptionalLoraV3ScopePicker/);
assert.match(quickEditorSource,/LoraV3ScopePicker/);
assert.match(quickEditorSource,/regionalLoraScopeViewProps\(node,loraV3Config,setLoraV3Config\)/);
assert.match(quickEditorSource,/quickLoraV3Target\(documentValue\.document_id,target\)/);
assert.match(quickEditorSource,/export function loadQuickLoraState/);
+12
View File
@@ -1,4 +1,5 @@
import test from "node:test";
import {applyRegionalCanvasSize} from "../ui/src/regional/milestoneE.ts";
import assert from "node:assert/strict";
import { applyRegionalPrimitiveDraft, migrateRegionalNode, migrationReportMessage, parseRegionalEditorDraft, persistRegionalEditorDraft, regionalEditorDraft } from "../ui/src/regional/milestoneE.ts";
import { layoutPanelIds, missingLayoutPanels } from "../ui/src/ui/layoutProfiles.ts";
@@ -7,6 +8,17 @@ import { canvasLegacyDragType, clearLegacyPortSticky, installLegacyPorts, legacy
const document = {schema:"bv.regional",version:2,document_id:"doc",title:"Original",canvas:{width:1024,height:1024},prompts:{global:{positive_source:"",negative_source:""},background:{positive_source:"",negative_source:""}},negative_mode:"auto",overlap:{mode:"joint"},regions:[]};
test('explicit canvas size preserves regions and unrelated drafts without masking undo dimensions',()=>{
const source={...document,regions:[{id:'r',geometry:[{x:.1,y:.2,width:.3,height:.4}]}]};
const draft=applyRegionalPrimitiveDraft(source,{title:'','canvas.width':'bad','canvas.height':'512'}).draft;
const result=applyRegionalCanvasSize(source,{width:736,height:920},draft);
assert.deepEqual(result.canonical.canvas,{width:736,height:920});assert.deepEqual(result.canonical.regions,source.regions);
assert.deepEqual(result.draft.raw,{title:''});assert.deepEqual(result.draft.issues.map(i=>i.field),['title']);
assert.equal(result.draft.raw['canvas.width']??source.canvas.width,1024);
assert.deepEqual(source.canvas,{width:1024,height:1024});assert.equal(draft.raw['canvas.width'],'bad');
assert.throws(()=>applyRegionalCanvasSize(source,{width:0,height:920},draft),/Invalid/);
});
test("Regional drafts preserve invalid raw values while canonical execution uses explicit fallbacks",()=>{
const result=applyRegionalPrimitiveDraft(document,{title:"", "canvas.width":"oops", "canvas.height":"2048"});
assert.equal(result.canonical.title,"Original");assert.equal(result.canonical.canvas.width,1024);assert.equal(result.canonical.canvas.height,2048);

Some files were not shown because too many files have changed in this diff Show More