Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3788b75a54 | ||
|
|
5b96e048eb | ||
|
|
387ff0d993 | ||
|
|
018745f568 | ||
|
|
db2dd28234 | ||
|
|
5c4b344201 |
@@ -2,3 +2,4 @@
|
||||
.comfyignore
|
||||
tests/
|
||||
ui/
|
||||
.tmp/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+2
-3
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]]:
|
||||
|
||||
@@ -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")
|
||||
@@ -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.")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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__,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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]);});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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>
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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={};
|
||||
@@ -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>
|
||||
@@ -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()}/>);
|
||||
@@ -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]));}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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});
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
@@ -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)});
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
});
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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%");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user