diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..89ccb83 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,54 @@ +name: Validate + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Validate Python syntax + run: python -m compileall -q __init__.py py + + - name: Run Python tests + run: python -m unittest discover -s tests -v + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Run frontend behavior tests + run: node --test tests/*.test.mjs + + - name: Install UI dependencies + working-directory: ui + run: npm ci + + - name: Type-check UI + working-directory: ui + run: npm run typecheck + + - name: Build UI + working-directory: ui + run: npm run build + + - name: Verify committed UI bundle + run: git diff --exit-code -- js/bv_nodepack.core.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e2443b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ + +ui/node_modules/ +ui/dist/ + +.idea/ +.vscode/ +*.log diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..a6a816b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,65 @@ +# BV Node Pack + +BV Node Pack provides composable workflow data carriers and user-interface building blocks for ComfyUI workflows and subgraphs. + +## Pipe language + +**Smart Pipe**: +A typed, incrementally extensible data carrier whose schema grows along a connected workflow path. +_Avoid_: Bus, global pipe + +**Pipe Schema**: +The ordered set of stable slots currently carried by a Smart Pipe. +_Avoid_: Config text, slot list + +**Pipe Slot**: +A stable, named and optionally typed position in a Pipe Schema. +_Avoid_: Port index, array position + +**Local Slot**: +A Pipe Slot introduced by the current Smart Pipe and added to its downstream schema. +_Avoid_: New input + +**Inherited Slot**: +A Pipe Slot received from an upstream Smart Pipe whose identity and name remain unchanged downstream. +_Avoid_: Copied slot + +**Missing Slot**: +An expected Inherited Slot whose stable identity is absent from the connected upstream Pipe Schema. +_Avoid_: Empty slot, null slot + +## Subgraph UI language + +**Subgraph UI Element**: +A persistent presentation element used to arrange exposed controls on a Subgraph, such as a Heading, Spacer, or Divider. +_Avoid_: Layout hack, dummy node + +**Subgraph Parameter Selector**: +A user-defined selection control whose visible options are also its emitted values. +_Avoid_: Dynamic text field + +**Layout Order**: +The user-defined, persistent ordering of exposed Subgraph inputs and Subgraph UI Elements. +_Avoid_: Widget index, creation order + +## Control Center language + +**Control**: +A user-defined toggle whose assignments participate in group-state resolution while the Control is active. +_Avoid_: Preset, workflow mode + +**Control Assignment**: +The association between a Control, a stably identified group, and its Activate, Mute, or Bypass action. +_Avoid_: Group title mapping + +**Control Conflict**: +Two or more active Controls assigning different actions to the same group. Resolution is deterministic: Activate wins over Mute, and Mute wins over Bypass. +_Avoid_: Last toggle wins + +**Rack**: +The workflow-level editor for defining Controls, Control Assignments, and Control Center behavior. +_Avoid_: Settings popup + +**Base Node State**: +The individual node mode that exists independently of restrictions applied by the Control Center. +_Avoid_: Previous index, default mode diff --git a/README.md b/README.md index 1e5135d..42b7460 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,16 @@ A curated collection of **quality-of-life**, **UI**, and **prompting** nodes for - [Installation](#installation) - [Update & Uninstall](#update--uninstall) - [Node Overview](#node-overview) + - [Example Workflows](#example-workflows) - [Pipe Nodes](#pipe-nodes) - [UI / Layout Nodes](#ui--layout-nodes) - [Prompt / AST Nodes](#prompt--ast-nodes) + - [Latent Nodes](#latent-nodes) - [Util Nodes](#util-nodes) - - [Beta Nodes](#beta-nodes) + - [Control Nodes](#control-nodes) - [Prompt AST Syntax & Markup Guide](#prompt-ast-syntax--markup-guide) - [Example application for AST](#example-application-for-ast) -- [Quick Start (BV Pipe)](#quick-start-bv-pipe) +- [Quick Start (BV Smart Pipe)](#quick-start-bv-smart-pipe) - [Notes & Design Principles](#notes--design-principles) - [Changelog](#changelog) @@ -72,10 +74,115 @@ Each section below can be read independently. --- +## Example Workflows + +Each preview is also an importable ComfyUI workflow: download the PNG and drag it onto the canvas. The matching JSON is provided as a metadata-independent fallback. + + +Smart Pipe: wireless branches and ordered merge + +[](examples/images/smart-pipe-wireless-merge.png) + +Two independently populated pipes are merged wirelessly and then inherited by the next processing stage. [Workflow JSON](examples/workflows/smart-pipe-wireless-merge.json) + + + + +Control Center: reusable workflow states + +[](examples/images/control-center-workflow-states.png) + +Multiple synchronized Control Center nodes manage grouped generation stages while showing their active state and conflict status directly on the graph. [Workflow JSON](examples/workflows/control-center-workflow-states.json) + + + + +Subgraph UI: heading, combo, divider and spacer + +[](examples/images/subgraph-ui-layout.png) + +The presentation nodes are arranged in the logical order in which they should be exposed on a purpose-built Subgraph interface. [Workflow JSON](examples/workflows/subgraph-ui-layout.json) + + + + +Random-ratio latent for composition exploration + +[](examples/images/empty-latent-random-ratio.png) + +A deterministic mix of common and project-specific aspect ratios feeds a sampler while width, height and ratio remain available for metadata and post-processing. [Workflow JSON](examples/workflows/empty-latent-random-ratio.json) + + + + +Prompt AST: category-based prompt variants + +[](examples/images/prompt-ast-categories.png) + +One structured prompt is filtered into a generation variant while the complete AST remains inspectable during workflow construction. [Workflow JSON](examples/workflows/prompt-ast-categories.json) + + + +--- + ## Pipe Nodes -BV Pipe Config +BV Smart Pipe + +A chain-growing pipe with stable slot identities and per-node projections. + +**What it does** +- Opens its schema editor through the Configure button or context menu +- Adds up to 100 local slots and inherits upstream slots +- Allows each node to expose only the inputs and outputs needed at that point +- Keeps branches independent and transports all values through the pipe connection +- Passes inherited values across bypassed Smart Pipe nodes without applying their local writes +- Treats a muted Smart Pipe as a temporary execution-branch stop without changing the saved workflow +- Omits muted Merge sources for the current run; a Merge with no active sources stops only its dependent branch +- Marks slots from a removed upstream producer as missing instead of shifting links +- Blocks execution when a missing slot is still connected + +Slot types start as `*` and are persisted after inference from the first connection. + +**Configure local slots** + + + +1. Add one or several local slot names. +2. Connect each local input so its type can be inferred and persisted. +3. Enable outputs only where a downstream node actually consumes the value. + +> **Experimental:** Wireless routing, especially across Subgraph boundaries, depends on a prompt-materialization adapter because ComfyUI does not yet expose an official pre-prompt extension hook. Keep physical Pipe links for compatibility-critical workflows until the Nodes 2.0, partial-execution and third-party wrapper matrix has been completed. + + + + +BV Smart Pipe Merge + +Combines several Smart Pipe branches in an explicit, user-defined order. + +**What it does** +- Mixes wired and wireless Smart Pipe sources +- Adds wired sources through the temporary `Add Pipe Source` connector +- Adds wireless sources through the configuration dialog +- Prevents the same source from being selected more than once +- Keeps different stable slot IDs separate even when their visible names match +- Preserves a genuine branch write when a later source only inherited the shared base value +- Lets the later source win when several branches genuinely changed the same stable slot ID +- Exposes the merged Pipe Schema to subsequent Smart Pipe nodes +- Rejects missing sources and cycles instead of silently producing incomplete data + +**Configure source order** + + + +Wireless and wired sources share the same ordered list. For colliding stable Slot IDs, the later genuine branch write wins. + + + + +BV Pipe Config [Deprecated] Defines the slot layout (names) for a BV Pipe. @@ -96,7 +203,7 @@ Defines the slot layout (names) for a BV Pipe. -BV Pipe +BV Pipe [Deprecated] A config-driven carrier node that forwards one pipe connection while exposing named slots. @@ -139,7 +246,7 @@ When exposed, the heading is also rendered on the Subgraph node itself. > Heading inside Subgraph > ->  +>  > Heading rendered on Subgraph node > @@ -243,6 +350,8 @@ A UI-only divider node that draws a horizontal separator line. - `ast` (`BV_AST`) - `cleaned_prompt` (plain text, no markup, no comments) +Comment text is removed while its line break is preserved. + **Screenshot** > Example output > @@ -362,9 +471,10 @@ Inline categories are only recognized if the category name matches this pattern: ### Closing inline categories (@@) ```text @@ closes one currently open inline category - -Extra @@ without an open category are treated as literal text and will appear in the output ``` + +Every inline category must be closed. An unmatched `@@` or an unclosed inline +category raises an error with its line and column. ### ❌ Invalid (too many closings) ```text @ long dress,@@@@ @@ -397,43 +507,37 @@ Extra @@ without an open category are treated as literal text and will appear in **Output** - `latent` (`LATENT`) - `latent_width` (`INT`) -- `latent_hieght` (`INT`) +- `latent_height` (`INT`) -BV Latent Random Aspect Ratio +BV Empty Latent Random Ratio **Purpose** -- Generates or resizes a latent to a randomly selected aspect ratio based on a seed -- Selection is deterministic via seed and configurable via enabled aspect-ratio toggles -- Uses a 1:1 base resolution reference (e.g. 1024×1024) commonly assumed for model training +- Creates a native empty latent at a deterministic aspect ratio +- Preserves the approximate pixel area of a square resolution reference +- Supports 512, 768, 1024, 1536, 2048, and a custom resolution **Input** -- `latent` (LATENT, optional) - - If connected, the current latent resolution is used as the 1:1 base reference - `seed` (INT) -- `enabled` (BOOLEAN) -- `base_resolution_px` (INT) - - Edge length of a square 1:1 reference in pixels (e.g. 1024 = 1024×1024), used when no latent is connected -- `use_1_1` (BOOLEAN) – Resolution 1×1 -- `use_3_2` (BOOLEAN) – Resolution 3×2 -- `use_2_3` (BOOLEAN) – Resolution 2×3 -- `use_4_3` (BOOLEAN) – Resolution 4×3 -- `use_3_4` (BOOLEAN) – Resolution 3×4 -- `use_16_9` (BOOLEAN) – Resolution 16×9 -- `use_9_16` (BOOLEAN) – Resolution 9×16 -- `use_21_9` (BOOLEAN) – Resolution 21×9 -- `use_9_21` (BOOLEAN) – Resolution 9×21 +- `resolution` (512, 768, 1024, 1536, 2048, or Custom) +- `custom_resolution` (INT, used for Custom) +- `alignment` (8, 16, 32, or 64; default 8) +- Individual toggles for 1:1, 3:2, 2:3, 4:3, 3:4, 16:9, 9:16, 21:9, and 9:21 +- `ratios` (optional multiline STRING for additional normalized `W:H` ratios) +- `batch_size` (INT) **Output** - `latent` (LATENT) -- `latent_width` (INT) -- `latent_height` (INT) +- `width` (INT, pixels) +- `height` (INT, pixels) - `picked_ratio` (STRING) - + +`BV Latent Random Aspect Ratio` remains available for existing workflows but is deprecated. ## Util Nodes @@ -443,10 +547,10 @@ Extra @@ without an open category are treated as literal text and will appear in BV Hex Color To Int **Purpose** -- Converts a HEX color code to an INT value +- Converts a three- or six-digit HEX color code to an INT value **Input** -- `prompt` (STRING) +- `hex` (STRING, `#RGB`, `RGB`, `#RRGGBB`, or `RRGGBB`) **Output** - `color` (`INT`) @@ -454,49 +558,44 @@ Extra @@ without an open category are treated as literal text and will appear in -## Beta Nodes +## Control Nodes -BV Control Center ⚠️ BETA +BV Control Center -Centralized runtime control for **mute / bypass** states of node groups. +Centralized runtime control for explicit **activate / mute / bypass** states of node groups. **Key Features** -- Group-based control -- Subgraph-compatible -- Dynamic UI reconfiguration +- User-defined, case-sensitive Controls with stable IDs +- Stable group assignments displayed with their full graph/subgraph path +- Multiple synchronized Control Center nodes and subgraph exposure +- Controls participate only while their toggle is `ACTIVE`; `INACTIVE` Controls do not apply assignments +- Deterministic overlap resolution with `Activate > Mute > Bypass` +- Conflicting active assignments are explained in the Rack and in a persistent Node status row +- Optional Force Active behavior; otherwise each node's base mode is restored +- Unresolved groups remain visible and block execution while their Control is active +- Event-driven updates without polling or retry loops -**Screenshot** -> Configuration -> ->  +**Configure workflow states** -> Selected group -> ->  + -> Define status -> ->  +1. Create a named Control such as `Generate only` or `Full pipeline`. +2. Assign one or more graph or Subgraph groups. +3. Choose `Activate`, `Mute`, or `Bypass` for each assignment. +4. Save once; every synchronized Control Center node updates immediately. ->Columns are automatically expanded -> -> - ->After saving the configuration, the `BV Control Center` Node updates itself. -> -> -> - ->Here is an example if, for example, Only Preview is active -> ->  + --- -## Quick Start (BV Pipe) +## Quick Start (BV Smart Pipe) + +For new workflows, add **BV Smart Pipe**, open **Configure Smart Pipe**, and add +only the slots needed at that point in the chain. The following legacy setup is +kept for existing workflows only. 1. Add **BV Pipe Config** and enter slot names: @@ -518,7 +617,8 @@ prompt ## Notes & Design Principles -- Slot IDs are stable internally (`v_001…v_100`) +- Smart Pipe slots use stable logical IDs; `v_001…` are only their persisted physical connector ordinals. +- Legacy `BV Pipe` retains its fixed 100-slot contract solely for existing workflows. - Labels are user-defined and dynamic - UI nodes are rendered via JavaScript - All UI nodes support Subgraphs / Subflows @@ -528,6 +628,9 @@ prompt ## Changelog +### 2026-08-14 +- v0.1.0 — Add BV Smart Pipe and native random-ratio empty latents; harden AST, Dynamic Combo, Subgraph UI, Control Rack, debug, rotation, and HEX nodes. + ### 2026-01-29 - v0.0.24 — Make subgraph UI widgets more rebuild-safe. diff --git a/__init__.py b/__init__.py index d0e9c6c..e308824 100644 --- a/__init__.py +++ b/__init__.py @@ -1,16 +1,6 @@ import importlib -import os from pathlib import Path -try: - import server - from aiohttp import web - import nodes -except ImportError: - server = None - web = None - nodes = None - NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -43,53 +33,3 @@ if nodes_dir.exists(): print(f"Failed to load module {module_name}: {e}") __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] - -print("Hello from node", flush=True) - -# Define the path to our extension -workspace_path = os.path.dirname(__file__) -dist_path = os.path.join(workspace_path, "dist/example_ext") -dist_locales_path = os.path.join(workspace_path, "dist/locales") - -# Print the current paths for debugging -print(f"BV-NodePack workspace path: {workspace_path}") -print(f"Dist path: {dist_path}") -print(f"Dist locales path: {dist_locales_path}") -print(f"Locales exist: {os.path.exists(dist_locales_path)}") - -# Register the static route for serving our React app assets -if os.path.exists(dist_path) and server is not None and web is not None: - # Add the routes for the extension - server.PromptServer.instance.app.add_routes([ - web.static("/example_ext/", dist_path), - ]) - - # Register the locale files route - if os.path.exists(dist_locales_path): - server.PromptServer.instance.app.add_routes([ - web.static("/locales/", dist_locales_path), - ]) - print(f"Registered locale files route at /locales/") - else: - print("WARNING: Locale directory not found!") - - # Also register the standard ComfyUI extension web directory - - project_name = os.path.basename(workspace_path) - - try: - # Method added in https://github.com/comfyanonymous/ComfyUI/pull/8357 - try: - from comfy_config import config_parser - project_config = config_parser.extract_node_configuration(workspace_path) - project_name = project_config.project.name - print(f"project name read from pyproject.toml: {project_name}") - except ImportError: - pass - except Exception as e: - print(f"Could not load project config, using default name '{project_name}': {e}") - - if nodes is not None: - nodes.EXTENSION_WEB_DIRS[project_name] = os.path.join(workspace_path, "dist") -else: - print("ComfyUI Example React Extension: Web directory not found") diff --git a/docs/adr/0001-separate-smart-pipe-contract.md b/docs/adr/0001-separate-smart-pipe-contract.md new file mode 100644 index 0000000..fc229d5 --- /dev/null +++ b/docs/adr/0001-separate-smart-pipe-contract.md @@ -0,0 +1,16 @@ +# ADR 0001: Introduce Smart Pipe as a separate contract + +- Status: Accepted +- Date: 2026-08-14 + +## Context + +The legacy `BV_PIPE` contract exposes 100 positional inputs and outputs and depends on a separate configuration node. Incremental schemas, branches, and deleted producers cannot be represented safely by array position. + +## Decision + +`BV Smart Pipe` uses a new `BV_SMART_PIPE` contract. Slots have stable IDs and physical ordinals, schemas grow locally along a chain, and missing inherited slots remain explicit tombstones. The legacy Pipe nodes remain registered only for existing workflows and are marked deprecated; no converter or interoperability layer is provided. + +## Consequences + +New workflows gain compact, independently configurable pipes without positional substitution. Existing legacy workflows still load, but the old Pipe implementation receives no new features. diff --git a/docs/adr/0002-renderer-independent-subgraph-ui.md b/docs/adr/0002-renderer-independent-subgraph-ui.md new file mode 100644 index 0000000..c83b8ca --- /dev/null +++ b/docs/adr/0002-renderer-independent-subgraph-ui.md @@ -0,0 +1,16 @@ +# ADR 0002: Keep Subgraph UI state independent of the renderer + +- Status: Accepted +- Date: 2026-08-14 + +## Context + +Subgraph presentation previously depended on rebuilt widget arrays and their current indices. Removing and re-adding an exposed input could therefore move headings, spacers, dividers, or selectors after a reload. + +## Decision + +Subgraph UI identity and layout order are serialized as stable node properties and never derived from widget indices or labels. Frontend hooks are scoped to the owning node classes and use public node/widget operations. Legacy Canvas rendering remains specialized; Nodes 2.0 may fall back to standard widgets where it does not expose the same presentation hook. + +## Consequences + +Identity and saved order survive label changes, duplication, and workflow reload independently of the renderer. Exact Nodes 2.0 presentation and authoritative outer Subgraph ordering still require live verification against its evolving public API. diff --git a/docs/adr/0003-wireless-smart-pipe-spine.md b/docs/adr/0003-wireless-smart-pipe-spine.md new file mode 100644 index 0000000..17aea95 --- /dev/null +++ b/docs/adr/0003-wireless-smart-pipe-spine.md @@ -0,0 +1,352 @@ +# ADR 0003: Optional wireless Smart Pipe spine + +- Status: Proposed +- Date: 2026-08-15 + +## Question + +Can the `BV_SMART_PIPE` connection between Smart Pipe nodes be made optional without +turning Smart Pipe into a global, type-based auto-wiring system? + +The proposal is deliberately narrow: only the `pipe` connection between two +`BV Smart Pipe` nodes may be virtual. Value-slot links from Smart Pipe outputs to +consumer nodes remain ordinary, visible ComfyUI links. + +## Findings + +### ComfyUI still needs a real execution dependency + +The frontend converts every active graph node, including flattened inner Subgraph +nodes, into API-prompt entries. Ordinary input links become `[origin_id, +origin_slot]` references. Links whose origin is absent from the prompt are removed. +Therefore a wireless pipe cannot merely share frontend state: before submission it +must become an equivalent prompt-level input reference so the backend can build the +correct dependency graph. + +Sources: + +- [ComfyUI frontend `graphToPrompt`: flattening, widget and link serialization](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/utils/executionUtil.ts#L24-L148) +- [ComfyUI queue path calls `graphToPrompt` before submitting the API prompt](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/scripts/app.ts#L1494-L1617) +- [ComfyUI backend creates its `DynamicPrompt` and `ExecutionList` from the submitted prompt](https://github.com/Comfy-Org/ComfyUI/blob/master/execution.py) + +The current Smart Pipe backend already has the correct runtime contract. Its +optional `pipe` input supplies the upstream stable-ID value map; local values +override inherited values. Wireless mode only needs to synthesize that one input +reference. No value-slot or consumer behavior needs to change. + +Source: + +- [`BVSmartPipe.run` and the `BV_SMART_PIPE` input contract](../../py/nodes/bv_smart_pipe.py) + +### Use Everywhere proves the technique, but its matching model is too broad + +Use Everywhere analyses unconnected inputs, finds compatible broadcasters, creates +temporary real graph links, invokes the original `graphToPrompt`, and restores the +graph afterwards. It wraps `app.graphToPrompt` and `app.queuePrompt` to enter this +process. Its conflict strategy sorts matching broadcasters by priority and refuses +equal-priority ties. + +Sources: + +- [Use Everywhere graph analysis and temporary modification lifecycle](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/js/use_everywhere_graph_analysis.js#L16-L135) +- [Use Everywhere temporary link creation and restoration](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/js/use_everywhere_apply.js#L99-L221) +- [Use Everywhere `graphToPrompt` interception](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/js/use_everywhere.js#L261-L305) +- [Use Everywhere deterministic priority handling and tie rejection](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/js/use_everywhere_classes.js#L348-L406) +- [Use Everywhere documented matching, priority, conflicts and Nodes 2.0 limitations](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/README.md#where-will-the-data-be-sent) + +Smart Pipe should not copy the global type/name matching model. `BV_SMART_PIPE` is +an ordered carrier with stable slot ownership, not an ambient value provider. A +wrong automatic match can silently inherit an entirely different schema and value +set. + +### There is no documented pre-prompt extension hook + +The public `ComfyExtension` interface exposes lifecycle hooks such as setup, +node-definition registration, node creation and graph configuration, but no +awaitable hook that can rewrite the API prompt immediately before submission. +Use Everywhere consequently wraps `app.graphToPrompt`, which is effective but is a +compatibility seam rather than a declared extension hook. + +Sources: + +- [Official `ComfyExtension` interface](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts#L100-L258) +- [Official extension development guide](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/extensions/development.md) +- [Current `ComfyApp.graphToPrompt` implementation](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/scripts/app.ts#L1494-L1498) + +This means the feature is feasible today, including Nodes 2.0, but it must be +isolated behind a small adapter and covered by integration tests because the hook +can change and multiple extensions may wrap the same method. + +### Subgraph identities and execution addresses + +ComfyUI distinguishes three relevant identities: + +1. A Subgraph **definition** has a UUID and contains the reusable inner nodes. +2. A Subgraph **instance** is an ordinary host node whose `type` references the + definition UUID. Instance properties are serialized separately from the shared + definition. +3. An expanded node has a `NodeExecutionId`: the colon-separated numeric host-node + path followed by the inner node ID, for example `12:45:7`. + +`ExecutableNodeDTO` constructs this execution ID from every containing Subgraph +instance node ID plus the definition-local inner node ID. Nested Subgraphs recurse +with the extended path. Consequently, two instances of the same definition produce +different API-prompt node IDs even though both wrap the same definition-local node. + +Sources: + +- [Official locator and execution-ID formats](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/nodeIdentification.ts#L3-L28) +- [`ExecutableNodeDTO` builds IDs from the Subgraph instance path](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts#L51-L105) +- [`SubgraphNode.getInnerNodes` recursively expands each instance](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/subgraph/SubgraphNode.ts#L739-L786) +- [Serialized Subgraph definitions versus instance properties](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/types/serialisation.ts#L31-L43) +- [`ExportedSubgraphInstance.type` references the reusable definition](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/types/serialisation.ts#L103-L117) +- [Official workflow flattening maps every definition to its instance execution paths](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/platform/workflow/core/utils/workflowFlattening.ts#L57-L99) + +The API-prompt address is therefore sufficient to materialize a direct dependency +between any two expanded Smart Pipe nodes, including root to Subgraph, Subgraph to +root, nested Subgraphs, and two different instances. The backend receives a flat +prompt graph and follows the referenced execution IDs. + +It is not suitable as the persisted wireless identity. Copy/paste or import may +assign a Subgraph host a new numeric node ID, changing every descendant execution +ID. A durable address must be resolved at prompt-build time from a persisted logical +address. + +### Durable composite address + +A stable logical address can be represented as: + +```text +[subgraph-instance-routing-uuid, ...] + pipe-routing-uuid +``` + +Every Subgraph host instance on the path needs its own serialized routing UUID. +Every Smart Pipe definition node needs its own routing UUID. The full parent path +distinguishes the same internal Pipe in multiple instances and nested instances. +At prompt build, this logical address maps to the current numeric `NodeExecutionId`. + +Copy rules must be explicit: + +- Copying a Subgraph **instance** keeps its definition reference and definition-local + Pipe UUIDs, but the copied host instance receives a new routing UUID. Its complete + descendant logical-address prefix is therefore new. +- Copying a whole parent instance recursively changes only the copied outer host + prefix; shared nested definition-local IDs remain valid beneath that new prefix. +- Creating an independent copied Subgraph **definition** must regenerate the copied + definition's internal routing UUIDs and repair routes inside that copied + definition. Otherwise the two definitions would share logical Pipe identities. +- Copying a root follower retains its old source address unless both endpoints are + part of a coordinated multi-node copy operation that can remap the copied route. + +The frontend already serializes arbitrary custom properties per Subgraph instance, +so storing a host routing UUID is structurally possible. Its current `clone()` does +not deep-clone the definition, reinforcing the distinction between duplicating an +instance and duplicating a definition. + +Sources: + +- [`SubgraphNode` is constructed from a shared definition plus instance data](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/subgraph/SubgraphNode.ts#L55-L99) +- [`SubgraphNode.serialize` persists instance properties and `clone` does not deep-clone definitions](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/subgraph/SubgraphNode.ts#L864-L908) +- [New Subgraph definitions receive their own UUID](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/LGraph.ts#L1643-L1669) + +### Why the address alone does not make cross-boundary routing safe + +Runtime addressing is solvable, but Smart Pipe currently stores its schema and +inherited projection on the inner definition node. That state is shared by every +instance. Instance A and instance B can receive different runtime values through +their different API-prompt IDs, but the same internal Smart Pipe cannot safely show +two different inherited schemas or two different predecessor configurations without +an additional host-scoped override model. + +Root to Subgraph and instance-to-instance routing therefore require more than an +address: + +- route edges must live in root-workflow or Subgraph-instance state, keyed by the + full logical destination address, rather than solely on the shared inner node; +- inherited schema projections must either remain identical across all instances or + become instance-scoped; +- the editor must know which concrete Subgraph instance path is being configured; +- copy/paste, definition duplication and instance deletion must remap or invalidate + the host-scoped route registry atomically. + +ComfyUI's official architecture explicitly treats Subgraph inputs and outputs as a +typed boundary contract and notes that per-instance values are naturally expressed +through those interface inputs. Use Everywhere likewise keeps broadcasting within +one graph and crosses boundaries through explicit Subgraph interfaces. + +Sources: + +- [Official typed Subgraph boundary model](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/subgraph-boundaries-and-promotion.md#2-graph-boundary-model) +- [Official analysis of shared-definition versus per-instance state](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/subgraph-boundaries-and-promotion.md#tradeoff-matrix) +- [Use Everywhere Subgraph boundary policy](https://github.com/chrisgoringe/cg-use-everywhere/blob/main/README.md#subgraphs) + +For the first wireless implementation, links should therefore resolve only within +the same graph scope. To cross a Subgraph boundary, expose and connect +`BV_SMART_PIPE` normally. Cross-boundary wireless routing remains technically +possible as a later feature, but only after introducing the host-scoped route and +schema-projection model; directly persisting numeric execution paths is rejected. + +## Considered ordering strategies + +### Canvas position + +Rejected. X/Y position is presentation state, changes during layout, and cannot +express branches reliably. + +### Node ID or creation order + +Rejected. Copy/paste, import, Subgraph instantiation and workflow migration can +change execution identities. Node IDs are identifiers, not domain ordering. + +### Channel plus priority/stage number + +Feasible, but not preferred. It imposes manual sequencing and makes insertion or +branching awkward. Equal values still require a conflict rule. + +### Stable predecessor identity + +Recommended. Each wireless Smart Pipe stores the stable routing identity of exactly +one predecessor in the same graph scope. The first node is a root. Several nodes may +follow the same predecessor, so branching is natural. The relationship is an +explicit edge even though it is not drawn as an ordinary cable. + +This removes the need for priority numbers. If the editor finds exactly one valid +candidate it may offer it as the default, but it must never silently choose among +multiple candidates. + +## Proposed model + +Each Smart Pipe receives serialized routing metadata independent of its slot schema: + +```json +{ + "version": 1, + "nodeId": "stable-node-uuid", + "mode": "wired | wireless-root | wireless-follow", + "predecessorId": "stable-node-uuid-or-null" +} +``` + +`nodeId` is a routing identity, not the ComfyUI numeric node ID. Duplicate routing +identities are repaired on copy/load by assigning the copy a new UUID. A follower's +`predecessorId` is retained when copying a single follower, making the copy another +branch from the same upstream node. + +For the proposed same-scope first version, `predecessorId` is definition-local to +that graph. A future cross-boundary version must replace it with the composite +instance-routing-UUID path described above and store instance-specific edges outside +the shared inner node. + +Rules: + +1. A physical `pipe` link is authoritative. Wireless metadata is ignored while the + physical link exists. +2. `wireless-root` has no predecessor. +3. `wireless-follow` requires exactly one live predecessor in the same graph scope. +4. The stored predecessor graph must be acyclic. Cycles block queueing with a clear + node-level error. +5. A deleted/missing predecessor is treated like a missing inherited producer and + blocks queueing while the follower or its outputs are used. +6. A bypassed Smart Pipe is transparently replaced by its nearest active predecessor, + matching ComfyUI's pass-through semantics while skipping the bypassed node's local + Slot writes. A muted Smart Pipe temporarily prunes its dependent branch from the API + prompt without changing the saved workflow. A Merge omits muted sources for that run + and remains active while at least one source is available; with no active sources, + the Merge and its exclusively dependent branch are pruned as well. Deleted or unknown + predecessors are never skipped. +7. Wireless routing changes only `pipe`. Local `v_NNN` inputs and all consumer links + remain untouched. + +## Prompt materialization + +Preferred implementation: + +1. Call the original `app.graphToPrompt` first. +2. Index the returned active `BV Smart Pipe` prompt entries by graph scope and stable + routing identity. +3. Validate uniqueness, same-scope predecessor references and acyclicity. +4. For each eligible follower without a physical `pipe` input, set only + `inputs.pipe = [predecessorExecutionId, 0]` in the returned API prompt. +5. Leave the serialized workflow and live LiteGraph graph unchanged. + +This is narrower than Use Everywhere's temporary graph mutation: it does not add or +remove canvas links, disturb autogrow inputs, or require graph restoration. Because +`graphToPrompt` includes all active nodes before dependency pruning, the predecessor +entry is available even though no visible pipe link led to it. + +The adapter must chain cooperatively by capturing the current `app.graphToPrompt`, +calling it with the original receiver and arguments, and modifying only its returned +prompt. It must be idempotent so extension load order does not create duplicate +effects. + +## UX + +Add an optional `Pipe Connection` section to the existing Smart Pipe editor: + +- `Wired` (default) +- `Wireless root` +- `Follow wirelessly` with a predecessor picker +- compact status on the node: channel/root or predecessor name, plus an error badge +- optional dashed preview line on selection/hover; no permanent canvas cable + +The picker should show title plus stable short ID and only Smart Pipes in the same +graph scope. A later convenience action may offer `Continue from selected Smart +Pipe`, which creates a correctly paired follower without exposing IDs or numbers. + +## Risks and required tests + +- Extension interoperability: test with other known `graphToPrompt` wrappers, + especially Use Everywhere and VHS-style extensions. +- Nodes 2.0 drift: test every supported frontend release because `graphToPrompt` is + not an extension hook. +- Save/load, copy/paste and undo/redo of root, follower and whole chains. +- Deleted, muted and bypassed predecessors. +- Cycle and duplicate-UUID rejection. +- Branches and multiple independent wireless chains in one graph. +- Same Subgraph definition instantiated more than once. +- Nested Subgraphs and explicit boundary connections. +- Subgraph instance copy versus independent definition duplication. +- Stable logical-address to current `NodeExecutionId` resolution after numeric ID + remapping. +- Partial execution: the synthesized prompt dependency must pull in all wireless + predecessors needed by the selected consumer. +- API export must contain materialized `pipe` links even though the saved editable + workflow retains wireless routing metadata. + +## Recommendation + +Proceed with a small prototype of **same-graph wireless Smart Pipe predecessors**. +Do not use global type matching, spatial order, node numbers or implicit priority. +The stable predecessor relationship gives deterministic order and branches, while +prompt-level materialization preserves ComfyUI's real execution tree. + +Keep the feature opt-in and marked experimental until the wrapper has passed the +Nodes 2.0, Subgraph, partial-execution and extension-interoperability matrix. If +ComfyUI later adds an official pre-prompt transformation hook, move the isolated +materializer to that hook without changing the routing model. + +The follow-up identity analysis confirms that a future composite address of +Subgraph-instance UUID path plus internal Pipe UUID can target root, nested and +cross-instance execution nodes deterministically. It does **not** remove the shared +definition-state problem. Cross-boundary wireless routing should remain deferred +until Smart Pipe gains host-scoped route edges and inherited-schema projections. + +## Follow-up decisions + +The product decision is to support cross-scope routing after the same-scope compiler +has been proven. Every root workflow and Subgraph host instance receives a stable +routing UUID. A durable node address is the root routing UUID, followed by every +containing host-instance routing UUID, followed by the Smart Pipe routing UUID. +Cross-scope routes live in a host-scoped route registry so shared Subgraph +definitions can have instance-specific predecessors and inherited-schema +projections. + +Smart Pipe branches are derived exclusively from predecessor edges; successor +lists, branch numbers and virtual links are never persisted. A future +`BV Smart Pipe Merge` is a separate node. Its ordered source list may mix physical +and wireless sources. Slot provenance is introduced before Merge so inherited base +values cannot overwrite genuine changes from another branch. + +New local value slots start with their output hidden. They are setters by default; +an output becomes visible only through explicit configuration or typed-output +wiring. diff --git a/examples/images/control-center-config.png b/examples/images/control-center-config.png new file mode 100644 index 0000000..bda8a12 Binary files /dev/null and b/examples/images/control-center-config.png differ diff --git a/examples/images/control-center-workflow-states.png b/examples/images/control-center-workflow-states.png new file mode 100644 index 0000000..de6cf29 Binary files /dev/null and b/examples/images/control-center-workflow-states.png differ diff --git a/examples/images/empty-latent-random-ratio.png b/examples/images/empty-latent-random-ratio.png new file mode 100644 index 0000000..1758259 Binary files /dev/null and b/examples/images/empty-latent-random-ratio.png differ diff --git a/examples/images/prompt-ast-categories.png b/examples/images/prompt-ast-categories.png new file mode 100644 index 0000000..d456fd7 Binary files /dev/null and b/examples/images/prompt-ast-categories.png differ diff --git a/examples/images/smart-pipe-merge-config.png b/examples/images/smart-pipe-merge-config.png new file mode 100644 index 0000000..b21a4c9 Binary files /dev/null and b/examples/images/smart-pipe-merge-config.png differ diff --git a/examples/images/smart-pipe-slot-config.png b/examples/images/smart-pipe-slot-config.png new file mode 100644 index 0000000..50dc945 Binary files /dev/null and b/examples/images/smart-pipe-slot-config.png differ diff --git a/examples/images/smart-pipe-wireless-merge.png b/examples/images/smart-pipe-wireless-merge.png new file mode 100644 index 0000000..393902e Binary files /dev/null and b/examples/images/smart-pipe-wireless-merge.png differ diff --git a/examples/images/subgraph-ui-layout.png b/examples/images/subgraph-ui-layout.png new file mode 100644 index 0000000..35f390b Binary files /dev/null and b/examples/images/subgraph-ui-layout.png differ diff --git a/examples/workflows/control-center-workflow-states.json b/examples/workflows/control-center-workflow-states.json new file mode 100644 index 0000000..c47098d --- /dev/null +++ b/examples/workflows/control-center-workflow-states.json @@ -0,0 +1,264 @@ +{ + "id": "e5ca783c-eb31-4599-9e4f-8e5eb06dc127", + "revision": 0, + "last_node_id": 6, + "last_link_id": 0, + "nodes": [ + { + "id": 3, + "type": "EmptyLatentImage", + "pos": [ + 540, + 310 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 0, + "mode": 4, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "bvControlBaseMode": 0 + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 2, + "type": "EmptyLatentImage", + "pos": [ + 540, + 80 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 512, + 512, + 1 + ] + }, + { + "id": 6, + "type": "BV Control Center", + "pos": [ + 30, + 150 + ], + "size": [ + 330, + 130 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "bv_control_config_json", + "name": "bv_control_config_json", + "type": "STRING", + "widget": { + "name": "bv_control_config_json" + }, + "link": null + }, + { + "label": "Generate only", + "name": "bvcc_991ba96f-f7f2-4406-b1ad-041002694862", + "nameLocked": true, + "type": "BOOLEAN", + "widget": { + "name": "bvcc_991ba96f-f7f2-4406-b1ad-041002694862" + }, + "link": null + }, + { + "label": "Full pipeline", + "name": "bvcc_02109f9f-5b3b-44dc-87be-16de1aa929b4", + "nameLocked": true, + "type": "BOOLEAN", + "widget": { + "name": "bvcc_02109f9f-5b3b-44dc-87be-16de1aa929b4" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "BV Control Center" + }, + "widgets_values": [ + "{\"version\":2,\"forceActive\":false,\"controls\":[{\"id\":\"991ba96f-f7f2-4406-b1ad-041002694862\",\"name\":\"Generate only\",\"enabled\":true,\"assignments\":[{\"groupId\":\"6922023a-aae5-4d59-86b0-1a21dafb98cc\",\"groupPath\":\"Upscale\",\"groupTitle\":\"Upscale\",\"action\":\"bypass\",\"unresolved\":false}]},{\"id\":\"02109f9f-5b3b-44dc-87be-16de1aa929b4\",\"name\":\"Full pipeline\",\"enabled\":false,\"assignments\":[{\"groupId\":\"db2a373c-9542-4657-ad6d-1a2dd6b3757c\",\"groupPath\":\"Generate\",\"groupTitle\":\"Generate\",\"action\":\"activate\",\"unresolved\":false}]}]}", + null, + true, + false + ], + "title": "🌀 BV Control Center" + } + ], + "links": [], + "groups": [ + { + "id": 2, + "title": "Upscale", + "bounding": [ + 500, + 250, + 350, + 205 + ], + "color": "#3f789e", + "flags": { + "bvGroupId": "6922023a-aae5-4d59-86b0-1a21dafb98cc" + } + }, + { + "id": 4, + "title": "Generate", + "bounding": [ + 500, + 20, + 350, + 205 + ], + "color": "#3f789e", + "flags": { + "bvGroupId": "db2a373c-9542-4657-ad6d-1a2dd6b3757c" + } + } + ], + "config": {}, + "extra": { + "bv_settings": { + "controlConfig": { + "version": 2, + "forceActive": false, + "controls": [ + { + "id": "991ba96f-f7f2-4406-b1ad-041002694862", + "name": "Generate only", + "enabled": true, + "assignments": [ + { + "groupId": "6922023a-aae5-4d59-86b0-1a21dafb98cc", + "groupPath": "Upscale", + "groupTitle": "Upscale", + "action": "bypass", + "unresolved": false + } + ] + }, + { + "id": "02109f9f-5b3b-44dc-87be-16de1aa929b4", + "name": "Full pipeline", + "enabled": false, + "assignments": [ + { + "groupId": "db2a373c-9542-4657-ad6d-1a2dd6b3757c", + "groupPath": "Generate", + "groupTitle": "Generate", + "action": "activate", + "unresolved": false + } + ] + } + ] + } + }, + "ds": { + "scale": 0.9, + "offset": [ + 140, + 170 + ] + } + }, + "version": 0.4 +} diff --git a/examples/workflows/empty-latent-random-ratio.json b/examples/workflows/empty-latent-random-ratio.json new file mode 100644 index 0000000..371351a --- /dev/null +++ b/examples/workflows/empty-latent-random-ratio.json @@ -0,0 +1,251 @@ +{ + "id": "81c10a3e-9c5e-426a-9eb9-ced999acd9b8", + "revision": 0, + "last_node_id": 3, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + 20, + 20 + ], + "size": [ + 430, + 220 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Random ratio latent – reproducible composition variants", + "properties": {}, + "widgets_values": [ + "## Practical use\nGenerate a deterministic mix of landscape, portrait and square canvases for prompt exploration. Standard ratios are one-click toggles; project-specific ratios stay in the text field. Connect **latent** directly to your sampler." + ], + "color": "#223", + "bgcolor": "#112" + }, + { + "id": 2, + "type": "BV Empty Latent Random Ratio", + "pos": [ + 500, + 20 + ], + "size": [ + 420, + 430 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "seed", + "type": "INT", + "link": null, + "widget": { + "name": "seed" + } + }, + { + "name": "resolution", + "type": "COMBO", + "link": null, + "widget": { + "name": "resolution" + } + }, + { + "name": "custom_resolution", + "type": "INT", + "link": null, + "widget": { + "name": "custom_resolution" + } + }, + { + "name": "alignment", + "type": "COMBO", + "link": null, + "widget": { + "name": "alignment" + } + }, + { + "name": "use_1_1", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_1_1" + } + }, + { + "name": "use_3_2", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_3_2" + } + }, + { + "name": "use_2_3", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_2_3" + } + }, + { + "name": "use_4_3", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_4_3" + } + }, + { + "name": "use_3_4", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_3_4" + } + }, + { + "name": "use_16_9", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_16_9" + } + }, + { + "name": "use_9_16", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_9_16" + } + }, + { + "name": "use_21_9", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_21_9" + } + }, + { + "name": "use_9_21", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "use_9_21" + } + }, + { + "name": "ratios", + "type": "STRING", + "link": null, + "widget": { + "name": "ratios" + } + }, + { + "name": "batch_size", + "type": "INT", + "link": null, + "widget": { + "name": "batch_size" + } + } + ], + "outputs": [ + { + "name": "latent", + "type": "LATENT", + "links": null + }, + { + "name": "width", + "type": "INT", + "links": null + }, + { + "name": "height", + "type": "INT", + "links": null + }, + { + "name": "picked_ratio", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Empty Latent Random Ratio", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + 42, + "1024", + 1024, + "64", + true, + true, + true, + false, + false, + true, + true, + false, + false, + "5:4\n4:5", + 1 + ] + }, + { + "id": 3, + "type": "MarkdownNote", + "pos": [ + 980, + 80 + ], + "size": [ + 360, + 190 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Suggested downstream wiring", + "properties": {}, + "widgets_values": [ + "**latent → KSampler.latent_image**\n\nThe additional width, height and picked_ratio outputs are useful for filenames, metadata and resolution-aware post-processing." + ], + "color": "#223", + "bgcolor": "#112" + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 80, + 80 + ] + }, + "frontendVersion": "1.48.7" + }, + "version": 0.4 +} diff --git a/examples/workflows/prompt-ast-categories.json b/examples/workflows/prompt-ast-categories.json new file mode 100644 index 0000000..2f027f0 --- /dev/null +++ b/examples/workflows/prompt-ast-categories.json @@ -0,0 +1,397 @@ +{ + "id": "0c3e4031-e10c-4f27-b327-5db14391c1d0", + "revision": 0, + "last_node_id": 6, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + 20, + 20 + ], + "size": [ + 430, + 210 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Prompt AST – categories instead of string surgery", + "properties": {}, + "widgets_values": [ + "## Practical use\nMaintain one structured prompt, enable or disable optional blocks, then decode only the categories required by a downstream encoder. The Debug node keeps the AST visible while building the workflow." + ], + "color": "#223", + "bgcolor": "#112" + }, + { + "id": 2, + "type": "BV Prompt Encode", + "pos": [ + 500, + 40 + ], + "size": [ + 360, + 240 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "prompt", + "type": "STRING", + "link": null, + "widget": { + "name": "prompt" + } + } + ], + "outputs": [ + { + "name": "ast", + "type": "BV_AST", + "links": [ + 1, + 2 + ] + }, + { + "name": "cleaned_prompt", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Prompt Encode", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + "A cinematic portrait of an astronaut\n@@lighting\nsoft rim light, volumetric haze\n@@detail\nhighly detailed helmet reflections\n@@negative\ntext, watermark" + ] + }, + { + "id": 3, + "type": "BV Prompt Category Switch", + "pos": [ + 930, + 40 + ], + "size": [ + 340, + 190 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "ast", + "type": "BV_AST", + "link": 1 + }, + { + "name": "enable_categories", + "type": "STRING", + "link": null, + "widget": { + "name": "enable_categories" + } + }, + { + "name": "disable_categories", + "type": "STRING", + "link": null, + "widget": { + "name": "disable_categories" + } + }, + { + "name": "inherit", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "inherit" + } + }, + { + "name": "mode", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "mode" + } + } + ], + "outputs": [ + { + "name": "ast", + "type": "BV_AST", + "links": [ + 3 + ] + }, + { + "name": "report", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Prompt Category Switch", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + "lighting\ndetail", + "negative", + true, + true + ] + }, + { + "id": 4, + "type": "BV Prompt Decode", + "pos": [ + 1340, + 40 + ], + "size": [ + 340, + 220 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "ast", + "type": "BV_AST", + "link": 3 + }, + { + "name": "category_filter", + "type": "STRING", + "link": null, + "widget": { + "name": "category_filter" + } + }, + { + "name": "with_comments", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "with_comments" + } + }, + { + "name": "inherit", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "inherit" + } + }, + { + "name": "mode", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "mode" + } + }, + { + "name": "prettify", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "prettify" + } + } + ], + "outputs": [ + { + "name": "ast", + "type": "BV_AST", + "links": null + }, + { + "name": "filtered_prompt", + "type": "STRING", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "BV Prompt Decode", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + "default, lighting, detail", + false, + true, + true, + true + ] + }, + { + "id": 5, + "type": "BV Prompt AST Debug", + "pos": [ + 930, + 300 + ], + "size": [ + 750, + 230 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "ast", + "type": "BV_AST", + "link": 2 + }, + { + "name": "pretty", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "pretty" + } + }, + { + "name": "indent", + "type": "INT", + "link": null, + "widget": { + "name": "indent" + } + }, + { + "name": "sort_keys", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "sort_keys" + } + } + ], + "outputs": [ + { + "name": "ast_json", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Prompt AST Debug", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + true, + 2, + false + ] + }, + { + "id": 6, + "type": "ShowText|pysssss", + "pos": [ + 1740, + 80 + ], + "size": [ + 260, + 90 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "link": 4 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "1.2.5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "A cinematic portrait of an astronaut\nsoft rim light, volumetric haze\nhighly detailed helmet reflections" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 3, + 0, + "BV_AST" + ], + [ + 2, + 2, + 0, + 5, + 0, + "BV_AST" + ], + [ + 3, + 3, + 0, + 4, + 0, + "BV_AST" + ], + [ + 4, + 4, + 1, + 6, + 0, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 80, + 80 + ] + }, + "frontendVersion": "1.48.7" + }, + "version": 0.4 +} diff --git a/examples/workflows/smart-pipe-wireless-merge.json b/examples/workflows/smart-pipe-wireless-merge.json new file mode 100644 index 0000000..e35d546 --- /dev/null +++ b/examples/workflows/smart-pipe-wireless-merge.json @@ -0,0 +1,933 @@ +{ + "id": "4e132e1a-a3fd-4a4c-860b-701948cc03cc", + "revision": 0, + "last_node_id": 209, + "last_link_id": 191, + "nodes": [ + { + "id": 204, + "type": "PrimitiveString", + "pos": [ + 20, + 30 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 185 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.33.1", + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "A lighthouse above a stormy sea" + ] + }, + { + "id": 199, + "type": "BV Smart Pipe", + "pos": [ + 320, + 20 + ], + "size": [ + 400, + 134 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "shape": 7, + "type": "BV_SMART_PIPE", + "link": null + }, + { + "label": "prompt", + "localized_name": "prompt", + "name": "v_001", + "shape": 7, + "type": "STRING", + "link": 185 + }, + { + "label": "style", + "localized_name": "style", + "name": "v_002", + "shape": 7, + "type": "STRING", + "link": 188 + }, + { + "label": "+ Add Slot", + "localized_name": "+ Add Slot", + "name": "bv_add_slot", + "type": "*", + "link": null + } + ], + "outputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "type": "BV_SMART_PIPE", + "links": null + } + ], + "title": "🌀 BV Smart Pipe Tree 1", + "properties": { + "bvSmartPipe": { + "version": 1, + "localSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": true, + "showOutput": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": true, + "showOutput": false + } + ], + "inheritedSlots": [], + "nextOrdinal": 3, + "routing": { + "version": 1, + "nodeId": "b92f4cea-21ae-47d9-9d88-1f82febf6b6a", + "name": "🌀 BV Smart Pipe Tree 1", + "mode": "root", + "predecessorId": null + }, + "resolvedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": true, + "showOutput": false, + "missing": false, + "dormant": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": true, + "showOutput": false, + "missing": false, + "dormant": false + } + ] + }, + "cnr_id": "bv_nodepack", + "ver": "f677a9d807da87226bbd7521f7c9f414b9a0d543", + "Node name for S&R": "BV Smart Pipe" + }, + "widgets_values": [ + "[{\"id\":\"8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef\",\"name\":\"prompt\",\"ordinal\":1,\"type\":\"STRING\",\"showInput\":true,\"showOutput\":false,\"missing\":false,\"dormant\":false,\"connected\":true},{\"id\":\"5c881b13-e3ab-4caf-9a09-92659fba6419\",\"name\":\"style\",\"ordinal\":2,\"type\":\"STRING\",\"showInput\":true,\"showOutput\":false,\"missing\":false,\"dormant\":false,\"connected\":true}]", + "{\"version\":1,\"nodeId\":\"b92f4cea-21ae-47d9-9d88-1f82febf6b6a\",\"name\":\"🌀 BV Smart Pipe Tree 1\",\"mode\":\"root\",\"predecessorId\":null}", + null, + null + ] + }, + { + "id": 198, + "type": "BV Smart Pipe", + "pos": [ + 320, + 300 + ], + "size": [ + 400, + 134 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "shape": 7, + "type": "BV_SMART_PIPE", + "link": null + }, + { + "label": "negative_prompt", + "localized_name": "negative_prompt", + "name": "v_001", + "shape": 7, + "type": "STRING", + "link": 187 + }, + { + "label": "+ Add Slot", + "localized_name": "+ Add Slot", + "name": "bv_add_slot", + "type": "*", + "link": null + } + ], + "outputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "type": "BV_SMART_PIPE", + "links": null + } + ], + "title": "🌀 BV Smart Pipe Tree 2", + "properties": { + "bvSmartPipe": { + "version": 1, + "localSlots": [ + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": true, + "showOutput": false + } + ], + "inheritedSlots": [], + "nextOrdinal": 2, + "routing": { + "version": 1, + "nodeId": "33bcd3e4-ef21-495c-84c5-71b0429eb3eb", + "name": "🌀 BV Smart Pipe Tree 2", + "mode": "root", + "predecessorId": null + }, + "resolvedSlots": [ + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": true, + "showOutput": false, + "missing": false, + "dormant": false + } + ] + }, + "cnr_id": "bv_nodepack", + "ver": "f677a9d807da87226bbd7521f7c9f414b9a0d543", + "Node name for S&R": "BV Smart Pipe" + }, + "widgets_values": [ + "[{\"id\":\"24bc820c-6056-4690-b39d-b4eb7c4159e5\",\"name\":\"negative_prompt\",\"ordinal\":1,\"type\":\"STRING\",\"showInput\":true,\"showOutput\":false,\"missing\":false,\"dormant\":false,\"connected\":true}]", + "{\"version\":1,\"nodeId\":\"33bcd3e4-ef21-495c-84c5-71b0429eb3eb\",\"name\":\"🌀 BV Smart Pipe Tree 2\",\"mode\":\"root\",\"predecessorId\":null}", + null, + null + ] + }, + { + "id": 201, + "type": "BV Smart Pipe", + "pos": [ + 1260, + 150 + ], + "size": [ + 400, + 112 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "shape": 7, + "type": "BV_SMART_PIPE", + "link": null + }, + { + "label": "+ Add Slot", + "localized_name": "+ Add Slot", + "name": "bv_add_slot", + "type": "*", + "link": null + } + ], + "outputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "type": "BV_SMART_PIPE", + "links": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "links": [ + 189 + ] + }, + { + "localized_name": "style", + "name": "style", + "type": "STRING", + "links": [ + 190 + ] + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "links": [ + 191 + ] + } + ], + "title": "🌀 BV Smart Pipe After Merge", + "properties": { + "bvSmartPipe": { + "version": 1, + "localSlots": [], + "inheritedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + } + ], + "nextOrdinal": 1, + "routing": { + "version": 1, + "nodeId": "9197f74f-ccda-4abd-a2bd-36f10a46dc37", + "name": "🌀 BV Smart Pipe After Merge", + "mode": "root", + "predecessorId": null + }, + "resolvedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + } + ] + }, + "cnr_id": "bv_nodepack", + "ver": "f677a9d807da87226bbd7521f7c9f414b9a0d543", + "Node name for S&R": "BV Smart Pipe" + }, + "widgets_values": [ + "[{\"id\":\"8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef\",\"name\":\"prompt\",\"ordinal\":1,\"type\":\"STRING\",\"showInput\":false,\"showOutput\":true,\"missing\":false,\"dormant\":false,\"connected\":true},{\"id\":\"5c881b13-e3ab-4caf-9a09-92659fba6419\",\"name\":\"style\",\"ordinal\":2,\"type\":\"STRING\",\"showInput\":false,\"showOutput\":true,\"missing\":false,\"dormant\":false,\"connected\":true},{\"id\":\"24bc820c-6056-4690-b39d-b4eb7c4159e5\",\"name\":\"negative_prompt\",\"ordinal\":1,\"type\":\"STRING\",\"showInput\":false,\"showOutput\":true,\"missing\":false,\"dormant\":false,\"connected\":true}]", + "{\"version\":1,\"nodeId\":\"9197f74f-ccda-4abd-a2bd-36f10a46dc37\",\"name\":\"🌀 BV Smart Pipe After Merge\",\"mode\":\"root\",\"predecessorId\":null}", + null, + null + ] + }, + { + "id": 200, + "type": "BV Smart Pipe Merge", + "pos": [ + 820, + 170 + ], + "size": [ + 400, + 88 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "bv_smart_pipe_merge_json", + "name": "bv_smart_pipe_merge_json", + "type": "STRING", + "widget": { + "name": "bv_smart_pipe_merge_json" + }, + "link": null + }, + { + "label": "+ Add Pipe Source", + "name": "bv_add_pipe_source", + "type": "BV_SMART_PIPE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "pipe", + "name": "pipe", + "type": "BV_SMART_PIPE", + "links": null + } + ], + "properties": { + "bvSmartPipeMerge": { + "version": 1, + "nodeId": "3fd0bf23-544a-4898-b436-d86f881e251b", + "name": "🌀 BV Smart Pipe Merge", + "sources": [ + { + "key": "pipe_001", + "mode": "wireless", + "address": "f049fca5-f1b5-4051-a0f6-efbc1165047d/b92f4cea-21ae-47d9-9d88-1f82febf6b6a", + "label": "🌀 BV Smart Pipe" + }, + { + "key": "pipe_002", + "mode": "wireless", + "address": "f049fca5-f1b5-4051-a0f6-efbc1165047d/33bcd3e4-ef21-495c-84c5-71b0429eb3eb", + "label": "🌀 BV Smart Pipe_1" + } + ], + "nextOrdinal": 9 + }, + "cnr_id": "bv_nodepack", + "ver": "f677a9d807da87226bbd7521f7c9f414b9a0d543", + "Node name for S&R": "BV Smart Pipe Merge", + "bvSmartPipe": { + "version": 1, + "localSlots": [], + "inheritedSlots": [], + "nextOrdinal": 1, + "routing": { + "version": 1, + "nodeId": "6ce82530-0e9b-4e0d-94bd-9b3c7fcc6477", + "name": "🌀 BV Smart Pipe Merge", + "mode": "root", + "predecessorId": null + } + } + }, + "widgets_values": [ + "[{\"key\":\"pipe_001\",\"mode\":\"wireless\",\"address\":\"f049fca5-f1b5-4051-a0f6-efbc1165047d/b92f4cea-21ae-47d9-9d88-1f82febf6b6a\",\"label\":\"🌀 BV Smart Pipe\"},{\"key\":\"pipe_002\",\"mode\":\"wireless\",\"address\":\"f049fca5-f1b5-4051-a0f6-efbc1165047d/33bcd3e4-ef21-495c-84c5-71b0429eb3eb\",\"label\":\"🌀 BV Smart Pipe_1\"}]", + null + ] + }, + { + "id": 205, + "type": "PrimitiveString", + "pos": [ + 20, + 310 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 187 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.33.1", + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "text, watermark" + ] + }, + { + "id": 206, + "type": "PrimitiveString", + "pos": [ + 20, + 150 + ], + "size": [ + 270, + 58 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 188 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.33.1", + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "cinematic" + ] + }, + { + "id": 207, + "type": "ShowText|pysssss", + "pos": [ + 1740, + 40 + ], + "size": [ + 260, + 90 + ], + "flags": {}, + "order": 207, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "link": 189 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "1.2.5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "A lighthouse above a stormy sea" + ] + }, + { + "id": 208, + "type": "ShowText|pysssss", + "pos": [ + 1740, + 170 + ], + "size": [ + 260, + 90 + ], + "flags": {}, + "order": 208, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "link": 190 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "1.2.5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "cinematic" + ] + }, + { + "id": 209, + "type": "ShowText|pysssss", + "pos": [ + 1740, + 300 + ], + "size": [ + 260, + 90 + ], + "flags": {}, + "order": 209, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "link": 191 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "shape": 6, + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "comfyui-custom-scripts", + "ver": "1.2.5", + "Node name for S&R": "ShowText|pysssss" + }, + "widgets_values": [ + "text, watermark" + ] + } + ], + "links": [ + [ + 185, + 204, + 0, + 199, + 1, + "STRING" + ], + [ + 187, + 205, + 0, + 198, + 1, + "STRING" + ], + [ + 188, + 206, + 0, + 199, + 2, + "STRING" + ], + [ + 189, + 201, + 1, + 207, + 0, + "STRING" + ], + [ + 190, + 201, + 2, + 208, + 0, + "STRING" + ], + [ + 191, + 201, + 3, + 209, + 0, + "STRING" + ] + ], + "groups": [], + "config": {}, + "extra": { + "bvSmartPipeRootId": "f049fca5-f1b5-4051-a0f6-efbc1165047d", + "bvSmartPipeRoutes": { + "f049fca5-f1b5-4051-a0f6-efbc1165047d/3fd0bf23-544a-4898-b436-d86f881e251b": { + "version": 1, + "kind": "merge", + "sources": [ + { + "key": "pipe_001", + "mode": "wireless", + "address": "f049fca5-f1b5-4051-a0f6-efbc1165047d/b92f4cea-21ae-47d9-9d88-1f82febf6b6a", + "label": "🌀 BV Smart Pipe" + }, + { + "key": "pipe_002", + "mode": "wireless", + "address": "f049fca5-f1b5-4051-a0f6-efbc1165047d/33bcd3e4-ef21-495c-84c5-71b0429eb3eb", + "label": "🌀 BV Smart Pipe_1" + } + ] + }, + "f049fca5-f1b5-4051-a0f6-efbc1165047d/9197f74f-ccda-4abd-a2bd-36f10a46dc37": { + "version": 1, + "predecessorAddress": "f049fca5-f1b5-4051-a0f6-efbc1165047d/3fd0bf23-544a-4898-b436-d86f881e251b", + "projection": { + "version": 1, + "inheritedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + } + ], + "resolvedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "5c881b13-e3ab-4caf-9a09-92659fba6419", + "name": "style", + "ordinal": 2, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + }, + { + "id": "24bc820c-6056-4690-b39d-b4eb7c4159e5", + "name": "negative_prompt", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": true, + "missing": false, + "dormant": false + } + ] + } + }, + "f049fca5-f1b5-4051-a0f6-efbc1165047d/aa5e59bb-8e36-47a0-835f-4680ea920f55": { + "version": 1, + "predecessorAddress": "f049fca5-f1b5-4051-a0f6-efbc1165047d/3fd0bf23-544a-4898-b436-d86f881e251b", + "projection": { + "version": 1, + "inheritedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "string", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": false, + "missing": false, + "dormant": false + } + ], + "resolvedSlots": [ + { + "id": "8ae4a8de-7caa-428b-8ee6-9e83bdefc3ef", + "name": "string", + "ordinal": 1, + "type": "STRING", + "showInput": false, + "showOutput": false, + "missing": false, + "dormant": false + } + ] + } + } + }, + "bv_settings": { + "controlConfig": { + "version": 2, + "forceActive": true, + "controls": [ + { + "id": "dc451bb5-f2fd-493d-9b23-60cccc096895", + "name": "Generate only", + "enabled": true, + "assignments": [ + { + "groupId": "0b5a1942-dce0-43c2-8602-67334deea095", + "groupPath": "Upscale", + "groupTitle": "Upscale", + "action": "bypass", + "unresolved": false + } + ] + }, + { + "id": "1717ed65-1353-4c24-8153-178819e8b83a", + "name": "Upscale only", + "enabled": true, + "assignments": [ + { + "groupId": "ab2f73e5-a1b5-49c9-b876-fd3e27613861", + "groupPath": "Generate", + "groupTitle": "Generate", + "action": "activate", + "unresolved": false + }, + { + "groupId": "0b5a1942-dce0-43c2-8602-67334deea095", + "groupPath": "Upscale", + "groupTitle": "Upscale", + "action": "mute", + "unresolved": false + } + ] + } + ] + } + }, + "ds": { + "scale": 0.72, + "offset": [ + 100, + 250 + ] + } + }, + "version": 0.4 +} diff --git a/examples/workflows/subgraph-ui-layout.json b/examples/workflows/subgraph-ui-layout.json new file mode 100644 index 0000000..d7d5bdc --- /dev/null +++ b/examples/workflows/subgraph-ui-layout.json @@ -0,0 +1,294 @@ +{ + "id": "b7d07ea1-bbe7-4d74-a568-98e0270ec59f", + "revision": 0, + "last_node_id": 6, + "last_link_id": 0, + "nodes": [ + { + "id": 1, + "type": "MarkdownNote", + "pos": [ + 20, + 20 + ], + "size": [ + 420, + 210 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Build a clean Subgraph interface", + "properties": {}, + "widgets_values": [ + "## Practical use\nExpose these inputs in the shown order. The outer Subgraph node becomes a small purpose-built UI: heading, model preset, divider, numeric settings and intentional spacing." + ], + "color": "#223", + "bgcolor": "#112" + }, + { + "id": 2, + "type": "BV Subgraph Heading", + "pos": [ + 500, + 20 + ], + "size": [ + 330, + 150 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "STRING", + "link": null, + "widget": { + "name": "value" + } + }, + { + "name": "font_size", + "type": "INT", + "link": null, + "widget": { + "name": "font_size" + } + }, + { + "name": "show_divider", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "show_divider" + } + } + ], + "outputs": [ + { + "name": "_", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Subgraph Heading", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + "Generation Settings", + 26, + false + ] + }, + { + "id": 3, + "type": "BV Dynamic Combo", + "pos": [ + 500, + 205 + ], + "size": [ + 330, + 210 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "STRING", + "link": null, + "widget": { + "name": "value" + } + }, + { + "name": "options", + "type": "STRING", + "link": null, + "widget": { + "name": "options" + } + } + ], + "outputs": [ + { + "name": "value_str", + "type": "STRING", + "links": null + }, + { + "name": "value_int", + "type": "INT", + "links": null + }, + { + "name": "value_float", + "type": "FLOAT", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Dynamic Combo", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + "Balanced", + "Fast\nBalanced\nQuality" + ] + }, + { + "id": 4, + "type": "BV Subgraph Divider", + "pos": [ + 900, + 70 + ], + "size": [ + 300, + 145 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "thickness", + "type": "INT", + "link": null, + "widget": { + "name": "thickness" + } + }, + { + "name": "padding", + "type": "INT", + "link": null, + "widget": { + "name": "padding" + } + }, + { + "name": "alpha", + "type": "FLOAT", + "link": null, + "widget": { + "name": "alpha" + } + } + ], + "outputs": [ + { + "name": "_", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Subgraph Divider", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + 2, + 10, + 0.35 + ] + }, + { + "id": 5, + "type": "BV Subgraph Spacer", + "pos": [ + 900, + 250 + ], + "size": [ + 300, + 125 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "height", + "type": "INT", + "link": null, + "widget": { + "name": "height" + } + }, + { + "name": "scale", + "type": "FLOAT", + "link": null, + "widget": { + "name": "scale" + } + } + ], + "outputs": [ + { + "name": "_", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "BV Subgraph Spacer", + "cnr_id": "bv_nodepack", + "ver": "0.1.0" + }, + "widgets_values": [ + 24, + 1 + ] + }, + { + "id": 6, + "type": "MarkdownNote", + "pos": [ + 1260, + 90 + ], + "size": [ + 380, + 210 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [], + "title": "Expose to the Subgraph input", + "properties": {}, + "widgets_values": [ + "Connect each presentation output to the Subgraph input bracket, then expose the functional values between them. Stable identities preserve this order across save/load and input changes." + ], + "color": "#223", + "bgcolor": "#112" + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 80, + 80 + ] + }, + "frontendVersion": "1.48.7" + }, + "version": 0.4 +} diff --git a/js/bv_dynamic_combo_model.js b/js/bv_dynamic_combo_model.js new file mode 100644 index 0000000..f188a82 --- /dev/null +++ b/js/bv_dynamic_combo_model.js @@ -0,0 +1,8 @@ +export function parseDynamicComboOptions(text) { + return String(text ?? "").split(/\r?\n/).map((value) => value.trim()).filter(Boolean); +} + +export function normalizeDynamicComboSelection(value, options) { + const current = String(value ?? ""); + return options.includes(current) || options.length === 0 ? current : options[0]; +} diff --git a/js/bv_latent_randome_ratio.js b/js/bv_latent_randome_ratio.js index 57233c8..c5c9485 100644 --- a/js/bv_latent_randome_ratio.js +++ b/js/bv_latent_randome_ratio.js @@ -1,10 +1,7 @@ import { app } from "../../scripts/app.js"; /** - * BV - Relabel aspect ratio toggle widgets for: - * "BV Latent Random Aspect Ratio" - * - * Keeps widget.name intact (use_1_1, use_3_2, ...) but changes widget.label to user-friendly text. + * Keeps aspect ratio widget names stable while presenting compact labels. */ const LABEL_MAP = { @@ -22,10 +19,7 @@ const LABEL_MAP = { function relabelAspectRatioWidgets(node) { if (!node || !node.widgets) return; - // Only for your node (internal name) - // NOTE: node.type is usually the internal type string. - // If this doesn't match in your build, log node.type once and adjust. - if (node.type !== "BV Latent Random Aspect Ratio") return; + if (node.type !== "BV Latent Random Aspect Ratio" && node.type !== "BV Empty Latent Random Ratio") return; for (const w of node.widgets) { if (!w || !w.name) continue; @@ -51,6 +45,7 @@ app.registerExtension({ }, async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "BV Latent Random Aspect Ratio" && nodeData.name !== "BV Empty Latent Random Ratio") return; // Hook creation const origCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { diff --git a/js/bv_nodepack.core.js b/js/bv_nodepack.core.js index aaebfa9..58815f0 100644 --- a/js/bv_nodepack.core.js +++ b/js/bv_nodepack.core.js @@ -1,4 +1,4 @@ -import{app as Rc}from"../../scripts/app.js";function Oc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ss={exports:{}},il={},as={exports:{}},R={};/** +import{app as Nc}from"../../scripts/app.js";function Pc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var rs={exports:{}},ol={},ls={exports:{}},T={};/** * @license React * react.production.min.js * @@ -6,7 +6,7 @@ import{app as Rc}from"../../scripts/app.js";function Oc(e){return e&&e.__esModul * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var er=Symbol.for("react.element"),Mc=Symbol.for("react.portal"),Dc=Symbol.for("react.fragment"),Ic=Symbol.for("react.strict_mode"),jc=Symbol.for("react.profiler"),Fc=Symbol.for("react.provider"),Uc=Symbol.for("react.context"),Ac=Symbol.for("react.forward_ref"),Vc=Symbol.for("react.suspense"),Bc=Symbol.for("react.memo"),$c=Symbol.for("react.lazy"),Yu=Symbol.iterator;function Wc(e){return e===null||typeof e!="object"?null:(e=Yu&&e[Yu]||e["@@iterator"],typeof e=="function"?e:null)}var cs={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},fs=Object.assign,ds={};function cn(e,t,n){this.props=e,this.context=t,this.refs=ds,this.updater=n||cs}cn.prototype.isReactComponent={};cn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};cn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ps(){}ps.prototype=cn.prototype;function Zo(e,t,n){this.props=e,this.context=t,this.refs=ds,this.updater=n||cs}var Jo=Zo.prototype=new ps;Jo.constructor=Zo;fs(Jo,cn.prototype);Jo.isPureReactComponent=!0;var Xu=Array.isArray,hs=Object.prototype.hasOwnProperty,qo={current:null},ms={key:!0,ref:!0,__self:!0,__source:!0};function vs(e,t,n){var r,l={},o=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(o=""+t.key),t)hs.call(t,r)&&!ms.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,X=_[H];if(0>>1;Hl(Nl,L))ktl(ur,Nl)?(_[H]=ur,_[kt]=L,H=kt):(_[H]=Nl,_[St]=L,H=St);else if(ktl(ur,L))_[H]=ur,_[kt]=L,H=kt;else break e}}return T}function l(_,T){var L=_.sortIndex-T.sortIndex;return L!==0?L:_.id-T.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var u=Date,i=u.now();e.unstable_now=function(){return u.now()-i}}var s=[],a=[],m=1,p=null,d=3,v=!1,S=!1,y=!1,P=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function h(_){for(var T=n(a);T!==null;){if(T.callback===null)r(a);else if(T.startTime<=_)r(a),T.sortIndex=T.expirationTime,t(s,T);else break;T=n(a)}}function g(_){if(y=!1,h(_),!S)if(n(s)!==null)S=!0,_l(C);else{var T=n(a);T!==null&&xl(g,T.startTime-_)}}function C(_,T){S=!1,y&&(y=!1,f(N),N=-1),v=!0;var L=d;try{for(h(T),p=n(s);p!==null&&(!(p.expirationTime>T)||_&&!me());){var H=p.callback;if(typeof H=="function"){p.callback=null,d=p.priorityLevel;var X=H(p.expirationTime<=T);T=e.unstable_now(),typeof X=="function"?p.callback=X:p===n(s)&&r(s),h(T)}else r(s);p=n(s)}if(p!==null)var or=!0;else{var St=n(a);St!==null&&xl(g,St.startTime-T),or=!1}return or}finally{p=null,d=L,v=!1}}var x=!1,E=null,N=-1,U=5,z=-1;function me(){return!(e.unstable_now()-z_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):U=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return d},e.unstable_getFirstCallbackNode=function(){return n(s)},e.unstable_next=function(_){switch(d){case 1:case 2:case 3:var T=3;break;default:T=d}var L=d;d=T;try{return _()}finally{d=L}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,T){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var L=d;d=_;try{return T()}finally{d=L}},e.unstable_scheduleCallback=function(_,T,L){var H=e.unstable_now();switch(typeof L=="object"&&L!==null?(L=L.delay,L=typeof L=="number"&&0H?(_.sortIndex=L,t(a,_),n(s)===null&&_===n(a)&&(y?(f(N),N=-1):y=!0,xl(g,L-H))):(_.sortIndex=X,t(s,_),S||v||(S=!0,_l(C))),_},e.unstable_shouldYield=me,e.unstable_wrapCallback=function(_){var T=d;return function(){var L=d;d=T;try{return _.apply(this,arguments)}finally{d=L}}}})(ks);Ss.exports=ks;var tf=Ss.exports;/** + */(function(e){function t(C,P){var z=C.length;C.push(P);e:for(;0>>1,X=C[W];if(0>>1;Wl(Cl,z))ytl(rr,Cl)?(C[W]=rr,C[yt]=z,W=yt):(C[W]=Cl,C[gt]=z,W=gt);else if(ytl(rr,z))C[W]=rr,C[yt]=z,W=yt;else break e}}return P}function l(C,P){var z=C.sortIndex-P.sortIndex;return z!==0?z:C.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var u=Date,s=u.now();e.unstable_now=function(){return u.now()-s}}var i=[],a=[],v=1,p=null,m=3,y=!1,w=!1,k=!1,j=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,c=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var P=n(a);P!==null;){if(P.callback===null)r(a);else if(P.startTime<=C)r(a),P.sortIndex=P.expirationTime,t(i,P);else break;P=n(a)}}function h(C){if(k=!1,d(C),!w)if(n(i)!==null)w=!0,Sl(E);else{var P=n(a);P!==null&&El(h,P.startTime-C)}}function E(C,P){w=!1,k&&(k=!1,f(N),N=-1),y=!0;var z=m;try{for(d(P),p=n(i);p!==null&&(!(p.expirationTime>P)||C&&!Ne());){var W=p.callback;if(typeof W=="function"){p.callback=null,m=p.priorityLevel;var X=W(p.expirationTime<=P);P=e.unstable_now(),typeof X=="function"?p.callback=X:p===n(i)&&r(i),d(P)}else r(i);p=n(i)}if(p!==null)var nr=!0;else{var gt=n(a);gt!==null&&El(h,gt.startTime-P),nr=!1}return nr}finally{p=null,m=z,y=!1}}var x=!1,_=null,N=-1,H=5,R=-1;function Ne(){return!(e.unstable_now()-RC||125W?(C.sortIndex=z,t(a,C),n(i)===null&&C===n(a)&&(k?(f(N),N=-1):k=!0,El(h,z-W))):(C.sortIndex=X,t(i,C),w||y||(w=!0,Sl(E))),C},e.unstable_shouldYield=Ne,e.unstable_wrapCallback=function(C){var P=m;return function(){var z=m;m=P;try{return C.apply(this,arguments)}finally{m=z}}}})(hs);vs.exports=hs;var Zc=vs.exports;/** * @license React * react-dom.production.min.js * @@ -30,11 +30,12 @@ import{app as Rc}from"../../scripts/app.js";function Oc(e){return e&&e.__esModul * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - */var nf=Oe,we=tf;function w(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),to=Object.prototype.hasOwnProperty,rf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ju={},qu={};function lf(e){return to.call(qu,e)?!0:to.call(Ju,e)?!1:rf.test(e)?qu[e]=!0:(Ju[e]=!0,!1)}function of(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function uf(e,t,n,r){if(t===null||typeof t>"u"||of(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function se(e,t,n,r,l,o,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=u}var ee={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ee[e]=new se(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ee[t]=new se(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ee[e]=new se(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ee[e]=new se(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ee[e]=new se(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ee[e]=new se(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ee[e]=new se(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ee[e]=new se(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ee[e]=new se(e,5,!1,e.toLowerCase(),null,!1,!1)});var eu=/[\-:]([a-z])/g;function tu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(eu,tu);ee[t]=new se(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(eu,tu);ee[t]=new se(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(eu,tu);ee[t]=new se(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ee[e]=new se(e,1,!1,e.toLowerCase(),null,!1,!1)});ee.xlinkHref=new se("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ee[e]=new se(e,1,!1,e.toLowerCase(),null,!0,!0)});function nu(e,t,n,r){var l=ee.hasOwnProperty(t)?ee[t]:null;(l!==null?l.type!==0:r||!(2"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ql=Object.prototype.hasOwnProperty,qc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Wu={},Qu={};function bc(e){return ql.call(Qu,e)?!0:ql.call(Wu,e)?!1:qc.test(e)?Qu[e]=!0:(Wu[e]=!0,!1)}function ef(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function tf(e,t,n,r){if(t===null||typeof t>"u"||ef(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function se(e,t,n,r,l,o,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=u}var ee={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ee[e]=new se(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ee[t]=new se(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ee[e]=new se(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ee[e]=new se(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ee[e]=new se(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ee[e]=new se(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ee[e]=new se(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ee[e]=new se(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ee[e]=new se(e,5,!1,e.toLowerCase(),null,!1,!1)});var Xo=/[\-:]([a-z])/g;function Zo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Xo,Zo);ee[t]=new se(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Xo,Zo);ee[t]=new se(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Xo,Zo);ee[t]=new se(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ee[e]=new se(e,1,!1,e.toLowerCase(),null,!1,!1)});ee.xlinkHref=new se("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ee[e]=new se(e,1,!1,e.toLowerCase(),null,!0,!0)});function Jo(e,t,n,r){var l=ee.hasOwnProperty(t)?ee[t]:null;(l!==null?l.type!==0:r||!(2i||l[u]!==o[i]){var s=` -`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=i);break}}}finally{Tl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?En(e):""}function sf(e){switch(e.tag){case 5:return En(e.type);case 16:return En("Lazy");case 13:return En("Suspense");case 19:return En("SuspenseList");case 0:case 2:case 15:return e=Ll(e.type,!1),e;case 11:return e=Ll(e.type.render,!1),e;case 1:return e=Ll(e.type,!0),e;default:return""}}function oo(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case At:return"Fragment";case Ut:return"Portal";case no:return"Profiler";case ru:return"StrictMode";case ro:return"Suspense";case lo:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case _s:return(e.displayName||"Context")+".Consumer";case Es:return(e._context.displayName||"Context")+".Provider";case lu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ou:return t=e.displayName||null,t!==null?t:oo(e.type)||"Memo";case qe:t=e._payload,e=e._init;try{return oo(e(t))}catch{}}return null}function af(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return oo(t);case 8:return t===ru?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ht(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ns(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function cf(e){var t=Ns(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ar(e){e._valueTracker||(e._valueTracker=cf(e))}function Ps(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ns(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function jr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function uo(e,t){var n=t.checked;return $({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ei(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ht(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function zs(e,t){t=t.checked,t!=null&&nu(e,"checked",t,!1)}function io(e,t){zs(e,t);var n=ht(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?so(e,t.type,n):t.hasOwnProperty("defaultValue")&&so(e,t.type,ht(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ti(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function so(e,t,n){(t!=="number"||jr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var _n=Array.isArray;function Zt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=cr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Un(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Pn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},ff=["Webkit","ms","Moz","O"];Object.keys(Pn).forEach(function(e){ff.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Pn[t]=Pn[e]})});function Os(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Pn.hasOwnProperty(e)&&Pn[e]?(""+t).trim():t+"px"}function Ms(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Os(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var df=$({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function fo(e,t){if(t){if(df[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(w(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(w(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(w(61))}if(t.style!=null&&typeof t.style!="object")throw Error(w(62))}}function po(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ho=null;function uu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var mo=null,Jt=null,qt=null;function li(e){if(e=rr(e)){if(typeof mo!="function")throw Error(w(280));var t=e.stateNode;t&&(t=dl(t),mo(e.stateNode,e.type,t))}}function Ds(e){Jt?qt?qt.push(e):qt=[e]:Jt=e}function Is(){if(Jt){var e=Jt,t=qt;if(qt=Jt=null,li(e),t)for(e=0;e>>=0,e===0?32:31-(Ef(e)/_f|0)|0}var fr=64,dr=4194304;function xn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Vr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=n&268435455;if(u!==0){var i=u&~l;i!==0?r=xn(i):(o&=u,o!==0&&(r=xn(o)))}else u=n&~l,u!==0?r=xn(u):o!==0&&(r=xn(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function tr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Me(t),e[t]=n}function zf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Tn),pi=" ",hi=!1;function ta(e,t){switch(e){case"keyup":return nd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function na(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Vt=!1;function ld(e,t){switch(e){case"compositionend":return na(t);case"keypress":return t.which!==32?null:(hi=!0,pi);case"textInput":return e=t.data,e===pi&&hi?null:e;default:return null}}function od(e,t){if(Vt)return e==="compositionend"||!hu&&ta(e,t)?(e=bs(),Pr=fu=nt=null,Vt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=yi(n)}}function ua(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ua(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ia(){for(var e=window,t=jr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=jr(e.document)}return t}function mu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function hd(e){var t=ia(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&ua(n.ownerDocument.documentElement,n)){if(r!==null&&mu(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=wi(n,o);var u=wi(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Bt=null,ko=null,Rn=null,Co=!1;function Si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Co||Bt==null||Bt!==jr(r)||(r=Bt,"selectionStart"in r&&mu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Rn&&Hn(Rn,r)||(Rn=r,r=Wr(ko,"onSelect"),0Ht||(e.current=zo[Ht],zo[Ht]=null,Ht--)}function I(e,t){Ht++,zo[Ht]=e.current,e.current=t}var mt={},le=yt(mt),fe=yt(!1),Tt=mt;function rn(e,t){var n=e.type.contextTypes;if(!n)return mt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function de(e){return e=e.childContextTypes,e!=null}function Qr(){F(fe),F(le)}function Pi(e,t,n){if(le.current!==mt)throw Error(w(168));I(le,t),I(fe,n)}function va(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(w(108,af(e)||"Unknown",l));return $({},n,r)}function Gr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||mt,Tt=le.current,I(le,e),I(fe,fe.current),!0}function zi(e,t,n){var r=e.stateNode;if(!r)throw Error(w(169));n?(e=va(e,t,Tt),r.__reactInternalMemoizedMergedChildContext=e,F(fe),F(le),I(le,e)):F(fe),I(fe,n)}var $e=null,pl=!1,Hl=!1;function ga(e){$e===null?$e=[e]:$e.push(e)}function Nd(e){pl=!0,ga(e)}function wt(){if(!Hl&&$e!==null){Hl=!0;var e=0,t=D;try{var n=$e;for(D=1;e>=u,l-=u,We=1<<32-Me(t)+l|n<N?(U=E,E=null):U=E.sibling;var z=d(f,E,h[N],g);if(z===null){E===null&&(E=U);break}e&&E&&z.alternate===null&&t(f,E),c=o(z,c,N),x===null?C=z:x.sibling=z,x=z,E=U}if(N===h.length)return n(f,E),A&&Ct(f,N),C;if(E===null){for(;NN?(U=E,E=null):U=E.sibling;var me=d(f,E,z.value,g);if(me===null){E===null&&(E=U);break}e&&E&&me.alternate===null&&t(f,E),c=o(me,c,N),x===null?C=me:x.sibling=me,x=me,E=U}if(z.done)return n(f,E),A&&Ct(f,N),C;if(E===null){for(;!z.done;N++,z=h.next())z=p(f,z.value,g),z!==null&&(c=o(z,c,N),x===null?C=z:x.sibling=z,x=z);return A&&Ct(f,N),C}for(E=r(f,E);!z.done;N++,z=h.next())z=v(E,f,N,z.value,g),z!==null&&(e&&z.alternate!==null&&E.delete(z.key===null?N:z.key),c=o(z,c,N),x===null?C=z:x.sibling=z,x=z);return e&&E.forEach(function(pn){return t(f,pn)}),A&&Ct(f,N),C}function P(f,c,h,g){if(typeof h=="object"&&h!==null&&h.type===At&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case sr:e:{for(var C=h.key,x=c;x!==null;){if(x.key===C){if(C=h.type,C===At){if(x.tag===7){n(f,x.sibling),c=l(x,h.props.children),c.return=f,f=c;break e}}else if(x.elementType===C||typeof C=="object"&&C!==null&&C.$$typeof===qe&&Ri(C)===x.type){n(f,x.sibling),c=l(x,h.props),c.ref=Sn(f,x,h),c.return=f,f=c;break e}n(f,x);break}else t(f,x);x=x.sibling}h.type===At?(c=zt(h.props.children,f.mode,g,h.key),c.return=f,f=c):(g=Ir(h.type,h.key,h.props,null,f.mode,g),g.ref=Sn(f,c,h),g.return=f,f=g)}return u(f);case Ut:e:{for(x=h.key;c!==null;){if(c.key===x)if(c.tag===4&&c.stateNode.containerInfo===h.containerInfo&&c.stateNode.implementation===h.implementation){n(f,c.sibling),c=l(c,h.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=ql(h,f.mode,g),c.return=f,f=c}return u(f);case qe:return x=h._init,P(f,c,x(h._payload),g)}if(_n(h))return S(f,c,h,g);if(mn(h))return y(f,c,h,g);wr(f,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,h),c.return=f,f=c):(n(f,c),c=Jl(h,f.mode,g),c.return=f,f=c),u(f)):n(f,c)}return P}var on=ka(!0),Ca=ka(!1),Xr=yt(null),Zr=null,Kt=null,wu=null;function Su(){wu=Kt=Zr=null}function ku(e){var t=Xr.current;F(Xr),e._currentValue=t}function Ro(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function en(e,t){Zr=e,wu=Kt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ce=!0),e.firstContext=null)}function Ne(e){var t=e._currentValue;if(wu!==e)if(e={context:e,memoizedValue:t,next:null},Kt===null){if(Zr===null)throw Error(w(308));Kt=e,Zr.dependencies={lanes:0,firstContext:e}}else Kt=Kt.next=e;return t}var xt=null;function Cu(e){xt===null?xt=[e]:xt.push(e)}function Ea(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Cu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ye(e,r)}function Ye(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var be=!1;function Eu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function _a(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Qe(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ct(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,M&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ye(e,n)}return l=r.interleaved,l===null?(t.next=t,Cu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ye(e,n)}function Tr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,su(e,n)}}function Oi(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=u:o=o.next=u,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Jr(e,t,n,r){var l=e.updateQueue;be=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,a=s.next;s.next=null,u===null?o=a:u.next=a,u=s;var m=e.alternate;m!==null&&(m=m.updateQueue,i=m.lastBaseUpdate,i!==u&&(i===null?m.firstBaseUpdate=a:i.next=a,m.lastBaseUpdate=s))}if(o!==null){var p=l.baseState;u=0,m=a=s=null,i=o;do{var d=i.lane,v=i.eventTime;if((r&d)===d){m!==null&&(m=m.next={eventTime:v,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var S=e,y=i;switch(d=t,v=n,y.tag){case 1:if(S=y.payload,typeof S=="function"){p=S.call(v,p,d);break e}p=S;break e;case 3:S.flags=S.flags&-65537|128;case 0:if(S=y.payload,d=typeof S=="function"?S.call(v,p,d):S,d==null)break e;p=$({},p,d);break e;case 2:be=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,d=l.effects,d===null?l.effects=[i]:d.push(i))}else v={eventTime:v,lane:d,tag:i.tag,payload:i.payload,callback:i.callback,next:null},m===null?(a=m=v,s=p):m=m.next=v,u|=d;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;d=i,i=d.next,d.next=null,l.lastBaseUpdate=d,l.shared.pending=null}}while(!0);if(m===null&&(s=p),l.baseState=s,l.firstBaseUpdate=a,l.lastBaseUpdate=m,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);Ot|=u,e.lanes=u,e.memoizedState=p}}function Mi(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Gl.transition;Gl.transition={};try{e(!1),t()}finally{D=n,Gl.transition=r}}function Ba(){return Pe().memoizedState}function Ld(e,t,n){var r=dt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},$a(e))Wa(t,n);else if(n=Ea(e,t,n,r),n!==null){var l=ue();De(n,e,r,l),Ha(n,t,r)}}function Rd(e,t,n){var r=dt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if($a(e))Wa(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var u=t.lastRenderedState,i=o(u,n);if(l.hasEagerState=!0,l.eagerState=i,Ie(i,u)){var s=t.interleaved;s===null?(l.next=l,Cu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Ea(e,t,l,r),n!==null&&(l=ue(),De(n,e,r,l),Ha(n,t,r))}}function $a(e){var t=e.alternate;return e===B||t!==null&&t===B}function Wa(e,t){On=br=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ha(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,su(e,n)}}var el={readContext:Ne,useCallback:te,useContext:te,useEffect:te,useImperativeHandle:te,useInsertionEffect:te,useLayoutEffect:te,useMemo:te,useReducer:te,useRef:te,useState:te,useDebugValue:te,useDeferredValue:te,useTransition:te,useMutableSource:te,useSyncExternalStore:te,useId:te,unstable_isNewReconciler:!1},Od={readContext:Ne,useCallback:function(e,t){return Fe().memoizedState=[e,t===void 0?null:t],e},useContext:Ne,useEffect:Ii,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Rr(4194308,4,ja.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Rr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Rr(4,2,e,t)},useMemo:function(e,t){var n=Fe();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Fe();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Ld.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=Fe();return e={current:e},t.memoizedState=e},useState:Di,useDebugValue:Ru,useDeferredValue:function(e){return Fe().memoizedState=e},useTransition:function(){var e=Di(!1),t=e[0];return e=Td.bind(null,e[1]),Fe().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=B,l=Fe();if(A){if(n===void 0)throw Error(w(407));n=n()}else{if(n=t(),J===null)throw Error(w(349));Rt&30||za(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Ii(La.bind(null,r,o,e),[e]),r.flags|=2048,qn(9,Ta.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=Fe(),t=J.identifierPrefix;if(A){var n=He,r=We;n=(r&~(1<<32-Me(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Zn++,0s||l[u]!==o[s]){var i=` +`+l[u].replace(" at new "," at ");return e.displayName&&i.includes("")&&(i=i.replace("",e.displayName)),i}while(1<=u&&0<=s);break}}}finally{Nl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?wn(e):""}function nf(e){switch(e.tag){case 5:return wn(e.type);case 16:return wn("Lazy");case 13:return wn("Suspense");case 19:return wn("SuspenseList");case 0:case 2:case 15:return e=Pl(e.type,!1),e;case 11:return e=Pl(e.type.render,!1),e;case 1:return e=Pl(e.type,!0),e;default:return""}}function no(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Mt:return"Fragment";case jt:return"Portal";case bl:return"Profiler";case qo:return"StrictMode";case eo:return"Suspense";case to:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ws:return(e.displayName||"Context")+".Consumer";case ys:return(e._context.displayName||"Context")+".Provider";case bo:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case eu:return t=e.displayName||null,t!==null?t:no(e.type)||"Memo";case qe:t=e._payload,e=e._init;try{return no(e(t))}catch{}}return null}function rf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return no(t);case 8:return t===qo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function dt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ss(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function lf(e){var t=Ss(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=lf(e))}function Es(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ss(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Or(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ro(e,t){var n=t.checked;return B({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ku(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=dt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Cs(e,t){t=t.checked,t!=null&&Jo(e,"checked",t,!1)}function lo(e,t){Cs(e,t);var n=dt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?oo(e,t.type,n):t.hasOwnProperty("defaultValue")&&oo(e,t.type,dt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Yu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function oo(e,t,n){(t!=="number"||Or(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var kn=Array.isArray;function Gt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=ir.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function jn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var xn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},of=["Webkit","ms","Moz","O"];Object.keys(xn).forEach(function(e){of.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),xn[t]=xn[e]})});function Ps(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||xn.hasOwnProperty(e)&&xn[e]?(""+t).trim():t+"px"}function zs(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Ps(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var uf=B({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function so(e,t){if(t){if(uf[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(g(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(g(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(g(61))}if(t.style!=null&&typeof t.style!="object")throw Error(g(62))}}function ao(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var co=null;function tu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var fo=null,Kt=null,Yt=null;function Ju(e){if(e=er(e)){if(typeof fo!="function")throw Error(g(280));var t=e.stateNode;t&&(t=cl(t),fo(e.stateNode,e.type,t))}}function Ts(e){Kt?Yt?Yt.push(e):Yt=[e]:Kt=e}function Ls(){if(Kt){var e=Kt,t=Yt;if(Yt=Kt=null,Ju(e),t)for(e=0;e>>=0,e===0?32:31-(yf(e)/wf|0)|0}var sr=64,ar=4194304;function Sn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Fr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=n&268435455;if(u!==0){var s=u&~l;s!==0?r=Sn(s):(o&=u,o!==0&&(r=Sn(o)))}else u=n&~l,u!==0?r=Sn(u):o!==0&&(r=Sn(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ie(t),e[t]=n}function Cf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Nn),ui=" ",ii=!1;function Zs(e,t){switch(e){case"keyup":return Jf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Js(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Dt=!1;function bf(e,t){switch(e){case"compositionend":return Js(t);case"keypress":return t.which!==32?null:(ii=!0,ui);case"textInput":return e=t.data,e===ui&&ii?null:e;default:return null}}function ed(e,t){if(Dt)return e==="compositionend"||!au&&Zs(e,t)?(e=Ys(),xr=uu=nt=null,Dt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=fi(n)}}function ta(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ta(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function na(){for(var e=window,t=Or();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Or(e.document)}return t}function cu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function ad(e){var t=na(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&ta(n.ownerDocument.documentElement,n)){if(r!==null&&cu(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=di(n,o);var u=di(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ft=null,yo=null,zn=null,wo=!1;function pi(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;wo||Ft==null||Ft!==Or(r)||(r=Ft,"selectionStart"in r&&cu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&Vn(zn,r)||(zn=r,r=Vr(yo,"onSelect"),0Vt||(e.current=_o[Vt],_o[Vt]=null,Vt--)}function M(e,t){Vt++,_o[Vt]=e.current,e.current=t}var pt={},le=vt(pt),fe=vt(!1),Nt=pt;function bt(e,t){var n=e.type.contextTypes;if(!n)return pt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function de(e){return e=e.childContextTypes,e!=null}function $r(){F(fe),F(le)}function ki(e,t,n){if(le.current!==pt)throw Error(g(168));M(le,t),M(fe,n)}function fa(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(g(108,rf(e)||"Unknown",l));return B({},n,r)}function Hr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||pt,Nt=le.current,M(le,e),M(fe,fe.current),!0}function Si(e,t,n){var r=e.stateNode;if(!r)throw Error(g(169));n?(e=fa(e,t,Nt),r.__reactInternalMemoizedMergedChildContext=e,F(fe),F(le),M(le,e)):F(fe),M(fe,n)}var Be=null,fl=!1,Bl=!1;function da(e){Be===null?Be=[e]:Be.push(e)}function Sd(e){fl=!0,da(e)}function ht(){if(!Bl&&Be!==null){Bl=!0;var e=0,t=O;try{var n=Be;for(O=1;e>=u,l-=u,$e=1<<32-Ie(t)+l|n<N?(H=_,_=null):H=_.sibling;var R=m(f,_,d[N],h);if(R===null){_===null&&(_=H);break}e&&_&&R.alternate===null&&t(f,_),c=o(R,c,N),x===null?E=R:x.sibling=R,x=R,_=H}if(N===d.length)return n(f,_),U&&wt(f,N),E;if(_===null){for(;NN?(H=_,_=null):H=_.sibling;var Ne=m(f,_,R.value,h);if(Ne===null){_===null&&(_=H);break}e&&_&&Ne.alternate===null&&t(f,_),c=o(Ne,c,N),x===null?E=Ne:x.sibling=Ne,x=Ne,_=H}if(R.done)return n(f,_),U&&wt(f,N),E;if(_===null){for(;!R.done;N++,R=d.next())R=p(f,R.value,h),R!==null&&(c=o(R,c,N),x===null?E=R:x.sibling=R,x=R);return U&&wt(f,N),E}for(_=r(f,_);!R.done;N++,R=d.next())R=y(_,f,N,R.value,h),R!==null&&(e&&R.alternate!==null&&_.delete(R.key===null?N:R.key),c=o(R,c,N),x===null?E=R:x.sibling=R,x=R);return e&&_.forEach(function(an){return t(f,an)}),U&&wt(f,N),E}function j(f,c,d,h){if(typeof d=="object"&&d!==null&&d.type===Mt&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case or:e:{for(var E=d.key,x=c;x!==null;){if(x.key===E){if(E=d.type,E===Mt){if(x.tag===7){n(f,x.sibling),c=l(x,d.props.children),c.return=f,f=c;break e}}else if(x.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===qe&&xi(E)===x.type){n(f,x.sibling),c=l(x,d.props),c.ref=hn(f,x,d),c.return=f,f=c;break e}n(f,x);break}else t(f,x);x=x.sibling}d.type===Mt?(c=_t(d.props.children,f.mode,h,d.key),c.return=f,f=c):(h=Ir(d.type,d.key,d.props,null,f.mode,h),h.ref=hn(f,c,d),h.return=f,f=h)}return u(f);case jt:e:{for(x=d.key;c!==null;){if(c.key===x)if(c.tag===4&&c.stateNode.containerInfo===d.containerInfo&&c.stateNode.implementation===d.implementation){n(f,c.sibling),c=l(c,d.children||[]),c.return=f,f=c;break e}else{n(f,c);break}else t(f,c);c=c.sibling}c=Xl(d,f.mode,h),c.return=f,f=c}return u(f);case qe:return x=d._init,j(f,c,x(d._payload),h)}if(kn(d))return w(f,c,d,h);if(fn(d))return k(f,c,d,h);hr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,c!==null&&c.tag===6?(n(f,c.sibling),c=l(c,d),c.return=f,f=c):(n(f,c),c=Yl(d,f.mode,h),c.return=f,f=c),u(f)):n(f,c)}return j}var tn=ha(!0),ga=ha(!1),Gr=vt(null),Kr=null,Ht=null,mu=null;function vu(){mu=Ht=Kr=null}function hu(e){var t=Gr.current;F(Gr),e._currentValue=t}function zo(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Zt(e,t){Kr=e,mu=Ht=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ce=!0),e.firstContext=null)}function xe(e){var t=e._currentValue;if(mu!==e)if(e={context:e,memoizedValue:t,next:null},Ht===null){if(Kr===null)throw Error(g(308));Ht=e,Kr.dependencies={lanes:0,firstContext:e}}else Ht=Ht.next=e;return t}var Et=null;function gu(e){Et===null?Et=[e]:Et.push(e)}function ya(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,gu(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ke(e,r)}function Ke(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var be=!1;function yu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function wa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function We(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function st(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,I&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ke(e,n)}return l=r.interleaved,l===null?(t.next=t,gu(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ke(e,n)}function Nr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ru(e,n)}}function _i(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=u:o=o.next=u,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Yr(e,t,n,r){var l=e.updateQueue;be=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,s=l.shared.pending;if(s!==null){l.shared.pending=null;var i=s,a=i.next;i.next=null,u===null?o=a:u.next=a,u=i;var v=e.alternate;v!==null&&(v=v.updateQueue,s=v.lastBaseUpdate,s!==u&&(s===null?v.firstBaseUpdate=a:s.next=a,v.lastBaseUpdate=i))}if(o!==null){var p=l.baseState;u=0,v=a=i=null,s=o;do{var m=s.lane,y=s.eventTime;if((r&m)===m){v!==null&&(v=v.next={eventTime:y,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var w=e,k=s;switch(m=t,y=n,k.tag){case 1:if(w=k.payload,typeof w=="function"){p=w.call(y,p,m);break e}p=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=k.payload,m=typeof w=="function"?w.call(y,p,m):w,m==null)break e;p=B({},p,m);break e;case 2:be=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[s]:m.push(s))}else y={eventTime:y,lane:m,tag:s.tag,payload:s.payload,callback:s.callback,next:null},v===null?(a=v=y,i=p):v=v.next=y,u|=m;if(s=s.next,s===null){if(s=l.shared.pending,s===null)break;m=s,s=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(v===null&&(i=p),l.baseState=i,l.firstBaseUpdate=a,l.lastBaseUpdate=v,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);Tt|=u,e.lanes=u,e.memoizedState=p}}function Ni(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Hl.transition;Hl.transition={};try{e(!1),t()}finally{O=n,Hl.transition=r}}function Da(){return _e().memoizedState}function _d(e,t,n){var r=ct(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Fa(e))Ua(t,n);else if(n=ya(e,t,n,r),n!==null){var l=ue();Oe(n,e,r,l),Aa(n,t,r)}}function Nd(e,t,n){var r=ct(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Fa(e))Ua(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var u=t.lastRenderedState,s=o(u,n);if(l.hasEagerState=!0,l.eagerState=s,je(s,u)){var i=t.interleaved;i===null?(l.next=l,gu(t)):(l.next=i.next,i.next=l),t.interleaved=l;return}}catch{}finally{}n=ya(e,t,l,r),n!==null&&(l=ue(),Oe(n,e,r,l),Aa(n,t,r))}}function Fa(e){var t=e.alternate;return e===V||t!==null&&t===V}function Ua(e,t){Tn=Zr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Aa(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ru(e,n)}}var Jr={readContext:xe,useCallback:te,useContext:te,useEffect:te,useImperativeHandle:te,useInsertionEffect:te,useLayoutEffect:te,useMemo:te,useReducer:te,useRef:te,useState:te,useDebugValue:te,useDeferredValue:te,useTransition:te,useMutableSource:te,useSyncExternalStore:te,useId:te,unstable_isNewReconciler:!1},Pd={readContext:xe,useCallback:function(e,t){return De().memoizedState=[e,t===void 0?null:t],e},useContext:xe,useEffect:zi,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,zr(4194308,4,Ra.bind(null,t,e),n)},useLayoutEffect:function(e,t){return zr(4194308,4,e,t)},useInsertionEffect:function(e,t){return zr(4,2,e,t)},useMemo:function(e,t){var n=De();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=De();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=_d.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=De();return e={current:e},t.memoizedState=e},useState:Pi,useDebugValue:Nu,useDeferredValue:function(e){return De().memoizedState=e},useTransition:function(){var e=Pi(!1),t=e[0];return e=xd.bind(null,e[1]),De().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=V,l=De();if(U){if(n===void 0)throw Error(g(407));n=n()}else{if(n=t(),J===null)throw Error(g(349));zt&30||Ca(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,zi(_a.bind(null,r,o,e),[e]),r.flags|=2048,Yn(9,xa.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=De(),t=J.identifierPrefix;if(U){var n=He,r=$e;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Gn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Ue]=t,e[Kn]=r,ec(e,t,!1,!1),t.stateNode=e;e:{switch(u=po(n,r),n){case"dialog":j("cancel",e),j("close",e),l=r;break;case"iframe":case"object":case"embed":j("load",e),l=r;break;case"video":case"audio":for(l=0;lan&&(t.flags|=128,r=!0,kn(o,!1),t.lanes=4194304)}else{if(!r)if(e=qr(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),kn(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!A)return ne(t),null}else 2*Q()-o.renderingStartTime>an&&n!==1073741824&&(t.flags|=128,r=!0,kn(o,!1),t.lanes=4194304);o.isBackwards?(u.sibling=t.child,t.child=u):(n=o.last,n!==null?n.sibling=u:t.child=u,o.last=u)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Q(),t.sibling=null,n=V.current,I(V,r?n&1|2:n&1),t):(ne(t),null);case 22:case 23:return Fu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ve&1073741824&&(ne(t),t.subtreeFlags&6&&(t.flags|=8192)):ne(t),null;case 24:return null;case 25:return null}throw Error(w(156,t.tag))}function Vd(e,t){switch(gu(t),t.tag){case 1:return de(t.type)&&Qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return un(),F(fe),F(le),Nu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return xu(t),null;case 13:if(F(V),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(w(340));ln()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F(V),null;case 4:return un(),null;case 10:return ku(t.type._context),null;case 22:case 23:return Fu(),null;case 24:return null;default:return null}}var kr=!1,re=!1,Bd=typeof WeakSet=="function"?WeakSet:Set,k=null;function Yt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){W(e,t,r)}else n.current=null}function Vo(e,t,n){try{n()}catch(r){W(e,t,r)}}var Gi=!1;function $d(e,t){if(Eo=Br,e=ia(),mu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var u=0,i=-1,s=-1,a=0,m=0,p=e,d=null;t:for(;;){for(var v;p!==n||l!==0&&p.nodeType!==3||(i=u+l),p!==o||r!==0&&p.nodeType!==3||(s=u+r),p.nodeType===3&&(u+=p.nodeValue.length),(v=p.firstChild)!==null;)d=p,p=v;for(;;){if(p===e)break t;if(d===n&&++a===l&&(i=u),d===o&&++m===r&&(s=u),(v=p.nextSibling)!==null)break;p=d,d=p.parentNode}p=v}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(_o={focusedElem:e,selectionRange:n},Br=!1,k=t;k!==null;)if(t=k,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,k=e;else for(;k!==null;){t=k;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var y=S.memoizedProps,P=S.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?y:Te(t.type,y),P);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(w(163))}}catch(g){W(t,t.return,g)}if(e=t.sibling,e!==null){e.return=t.return,k=e;break}k=t.return}return S=Gi,Gi=!1,S}function Mn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Vo(t,n,o)}l=l.next}while(l!==r)}}function vl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Bo(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function rc(e){var t=e.alternate;t!==null&&(e.alternate=null,rc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ue],delete t[Kn],delete t[Po],delete t[_d],delete t[xd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function lc(e){return e.tag===5||e.tag===3||e.tag===4}function Ki(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||lc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function $o(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Hr));else if(r!==4&&(e=e.child,e!==null))for($o(e,t,n),e=e.sibling;e!==null;)$o(e,t,n),e=e.sibling}function Wo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Wo(e,t,n),e=e.sibling;e!==null;)Wo(e,t,n),e=e.sibling}var q=null,Le=!1;function Je(e,t,n){for(n=n.child;n!==null;)oc(e,t,n),n=n.sibling}function oc(e,t,n){if(Ae&&typeof Ae.onCommitFiberUnmount=="function")try{Ae.onCommitFiberUnmount(sl,n)}catch{}switch(n.tag){case 5:re||Yt(n,t);case 6:var r=q,l=Le;q=null,Je(e,t,n),q=r,Le=l,q!==null&&(Le?(e=q,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):q.removeChild(n.stateNode));break;case 18:q!==null&&(Le?(e=q,n=n.stateNode,e.nodeType===8?Wl(e.parentNode,n):e.nodeType===1&&Wl(e,n),$n(e)):Wl(q,n.stateNode));break;case 4:r=q,l=Le,q=n.stateNode.containerInfo,Le=!0,Je(e,t,n),q=r,Le=l;break;case 0:case 11:case 14:case 15:if(!re&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&(o&2||o&4)&&Vo(n,t,u),l=l.next}while(l!==r)}Je(e,t,n);break;case 1:if(!re&&(Yt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){W(n,t,i)}Je(e,t,n);break;case 21:Je(e,t,n);break;case 22:n.mode&1?(re=(r=re)||n.memoizedState!==null,Je(e,t,n),re=r):Je(e,t,n);break;default:Je(e,t,n)}}function Yi(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Bd),t.forEach(function(r){var l=Jd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=Q()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Hd(r/1960))-r,10e?16:e,rt===null)var r=!1;else{if(e=rt,rt=null,rl=0,M&6)throw Error(w(331));var l=M;for(M|=4,k=e.current;k!==null;){var o=k,u=o.child;if(k.flags&16){var i=o.deletions;if(i!==null){for(var s=0;sQ()-Iu?Pt(e,0):Du|=n),pe(e,t)}function pc(e,t){t===0&&(e.mode&1?(t=dr,dr<<=1,!(dr&130023424)&&(dr=4194304)):t=1);var n=ue();e=Ye(e,t),e!==null&&(tr(e,t,n),pe(e,n))}function Zd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),pc(e,n)}function Jd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(w(314))}r!==null&&r.delete(t),pc(e,n)}var hc;hc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||fe.current)ce=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ce=!1,Ud(e,t,n);ce=!!(e.flags&131072)}else ce=!1,A&&t.flags&1048576&&ya(t,Yr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Or(e,t),e=t.pendingProps;var l=rn(t,le.current);en(t,n),l=zu(null,t,r,e,l,n);var o=Tu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,de(r)?(o=!0,Gr(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Eu(t),l.updater=ml,t.stateNode=l,l._reactInternals=t,Mo(t,r,e,n),t=jo(null,t,r,!0,o,n)):(t.tag=0,A&&o&&vu(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Or(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=bd(r),e=Te(r,e),l){case 0:t=Io(null,t,r,e,n);break e;case 1:t=Wi(null,t,r,e,n);break e;case 11:t=Bi(null,t,r,e,n);break e;case 14:t=$i(null,t,r,Te(r.type,e),n);break e}throw Error(w(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Io(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Wi(e,t,r,l,n);case 3:e:{if(Ja(t),e===null)throw Error(w(387));r=t.pendingProps,o=t.memoizedState,l=o.element,_a(e,t),Jr(t,r,null,n);var u=t.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=sn(Error(w(423)),t),t=Hi(e,t,r,n,l);break e}else if(r!==l){l=sn(Error(w(424)),t),t=Hi(e,t,r,n,l);break e}else for(ge=at(t.stateNode.containerInfo.firstChild),ye=t,A=!0,Re=null,n=Ca(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ln(),r===l){t=Xe(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return xa(t),e===null&&Lo(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,xo(r,l)?u=null:o!==null&&xo(r,o)&&(t.flags|=32),Za(e,t),oe(e,t,u,n),t.child;case 6:return e===null&&Lo(t),null;case 13:return qa(e,t,n);case 4:return _u(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=on(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Bi(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,u=l.value,I(Xr,r._currentValue),r._currentValue=u,o!==null)if(Ie(o.value,u)){if(o.children===l.children&&!fe.current){t=Xe(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var i=o.dependencies;if(i!==null){u=o.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=Qe(-1,n&-n),s.tag=2;var a=o.updateQueue;if(a!==null){a=a.shared;var m=a.pending;m===null?s.next=s:(s.next=m.next,m.next=s),a.pending=s}}o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Ro(o.return,n,t),i.lanes|=n;break}s=s.next}}else if(o.tag===10)u=o.type===t.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(w(341));u.lanes|=n,i=u.alternate,i!==null&&(i.lanes|=n),Ro(u,n,t),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===t){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,en(t,n),l=Ne(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Te(r,t.pendingProps),l=Te(r.type,l),$i(e,t,r,l,n);case 15:return Ya(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Or(e,t),t.tag=1,de(r)?(e=!0,Gr(t)):e=!1,en(t,n),Qa(t,r,l),Mo(t,r,l,n),jo(null,t,r,!0,e,n);case 19:return ba(e,t,n);case 22:return Xa(e,t,n)}throw Error(w(156,t.tag))};function mc(e,t){return $s(e,t)}function qd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function _e(e,t,n,r){return new qd(e,t,n,r)}function Au(e){return e=e.prototype,!(!e||!e.isReactComponent)}function bd(e){if(typeof e=="function")return Au(e)?1:0;if(e!=null){if(e=e.$$typeof,e===lu)return 11;if(e===ou)return 14}return 2}function pt(e,t){var n=e.alternate;return n===null?(n=_e(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ir(e,t,n,r,l,o){var u=2;if(r=e,typeof e=="function")Au(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case At:return zt(n.children,l,o,t);case ru:u=8,l|=8;break;case no:return e=_e(12,n,t,l|2),e.elementType=no,e.lanes=o,e;case ro:return e=_e(13,n,t,l),e.elementType=ro,e.lanes=o,e;case lo:return e=_e(19,n,t,l),e.elementType=lo,e.lanes=o,e;case xs:return yl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Es:u=10;break e;case _s:u=9;break e;case lu:u=11;break e;case ou:u=14;break e;case qe:u=16,r=null;break e}throw Error(w(130,e==null?e:typeof e,""))}return t=_e(u,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function zt(e,t,n,r){return e=_e(7,e,r,t),e.lanes=n,e}function yl(e,t,n,r){return e=_e(22,e,r,t),e.elementType=xs,e.lanes=n,e.stateNode={isHidden:!1},e}function Jl(e,t,n){return e=_e(6,e,null,t),e.lanes=n,e}function ql(e,t,n){return t=_e(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function ep(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ol(0),this.expirationTimes=Ol(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ol(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Vu(e,t,n,r,l,o,u,i,s){return e=new ep(e,t,n,i,s),t===1?(t=1,o===!0&&(t|=8)):t=0,o=_e(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Eu(o),e}function tp(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(wc)}catch(e){console.error(e)}}wc(),ws.exports=Se;var up=ws.exports,ns=up;eo.createRoot=ns.createRoot,eo.hydrateRoot=ns.hydrateRoot;function he(){return Rc}const ip=he();ip.rootGraph;function Sc(){return he()?.rootGraph??null}function rs(e){var n;const t=Sc();if(!t){console.warn("[BV] rootGraph not ready yet, cannot write config");return}t.extra??(t.extra={}),(n=t.extra).bv_settings??(n.bv_settings={}),t.extra.bv_settings.controlConfig=e,t.setDirtyCanvas?.(!0,!0)}function vt(){return Sc()?.extra?.bv_settings?.controlConfig??null}const sp=({index:e,row:t,maxCols:n,onChange:r,groups:l})=>{Oe.useEffect(()=>{const i=l.map(a=>a.group.title),s=t.entries.filter(a=>a.kind==="group"?i.includes(a.ref.title):!0);s.length!==t.entries.length&&r(e,{...t,entries:s},!1)},[l,t.entries,e,r,t.title]);const o=(i,s)=>{if(i===null){const p={kind:"group",ref:{title:s},action:"bypass"};r(e,{...t,entries:[...t.entries,p]},!1);return}if(s=="Nothing Selected"){r(e,{...t,entries:t.entries.filter((p,d)=>d!==i)},!1);return}const m={...t.entries[i],kind:"group",ref:{title:s}};r(e,{...t,entries:[...t.entries.slice(0,i),m,...t.entries.slice(i+1)]},!1)},u=(i,s)=>{const m={...t.entries[i],action:s};r(e,{...t,entries:[...t.entries.slice(0,i),m,...t.entries.slice(i+1)]},!1)};return O.jsxs(O.Fragment,{children:[O.jsx("div",{children:O.jsx("input",{type:"text",value:t.title,onChange:i=>r(e,{...t,title:i.target.value},!1)})}),t.entries.map((i,s)=>O.jsxs("div",{style:{display:"flex",gap:"4px"},children:[O.jsxs("select",{value:i.ref.title,onChange:a=>o(s,a.target.value),children:[O.jsx("option",{children:"Nothing Selected"}),l.map(a=>O.jsx("option",{children:a.group.title},a.group.title))]}),O.jsxs("select",{value:i.action,onChange:a=>u(s,a.target.value),children:[O.jsx("option",{value:"mute",children:"Mute"}),O.jsx("option",{value:"bypass",children:"Bypass"})]})]},s)),O.jsx("div",{children:O.jsxs("select",{value:"Nothing Selected",onChange:i=>o(null,i.target.value),children:[O.jsx("option",{children:"Nothing Selected"}),l.map(i=>O.jsx("option",{children:i.group.title},i.group.title))]})}),Array.from({length:n-t.entries.length-3}).map((i,s)=>O.jsx("div",{},`empty_${s}`)),O.jsx("div",{children:O.jsx("button",{onClick:()=>r(e,t,!0),children:"Delete"})})]})};function ap(e){if(!e||typeof e!="object")return null;const t=e,n=t.rootGraph;if(n&&typeof n=="object")return n;const r=t.graph;return r&&typeof r=="object"?r:e}function cp(e){return e._nodes??e.nodes??[]}function fp(e){return e._groups??e.groups??[]}function dp(e){if(e.subgraph&&typeof e.subgraph=="object")return e.subgraph;if(typeof e.getSubgraph=="function")try{return e.getSubgraph()??null}catch{return null}return null}function pp(e,t){const n=new Set,r=[{graph:e,path:[]}];for(;r.length;){const{graph:l,path:o}=r.pop(),u=l;if(!n.has(u)){n.add(u),t(l,o);for(const i of cp(l)){const s=dp(i);s&&r.push({graph:s,path:o.concat([{node:i,graph:l,depth:o.length+1}])})}}}}function hp(e){const t=ap(e);if(!t)return[];const n=[];return pp(t,(r,l)=>{for(const o of fp(r))n.push({group:o,graph:r,path:l})}),n}const mp=({onClose:e})=>{const[t,n]=Oe.useState(vt()),r=he(),l=Oe.useMemo(()=>hp(r),[r,t]),o=()=>{if(t){const m="New Row";let p=m,d=1;for(;t.rows.some(v=>v.title===p);)p=`${m} ${d}`,d++;n({rows:[...t.rows,{title:p,entries:[]}]})}else n({rows:[{title:"New Row",entries:[]}]})},u=()=>{rs(t)},i=()=>{rs(null),n(null)},s=(m,p,d)=>{if(t)if(d)n({...t,rows:t.rows.filter((v,S)=>S!==m)});else{if(t.rows.some((S,y)=>y!==m&&S.title===p.title)&&t.rows[m].title!==p.title){let y=p.title,P=1;for(;t.rows.some((f,c)=>c!==m&&f.title===y);)y=`${p.title} ${P}`,P++;p={...p,title:y}}n({...t,rows:t.rows.map((S,y)=>y===m?p:S)})}},a=(t?.rows.reduce((m,p)=>Math.max(m,p.entries.length),0)??0)+3;return O.jsxs("div",{className:"grid grid-cols-1 gap-4 p-4",children:[t&&O.jsxs("div",{className:"grid gap-4 p-4 items-center",style:{gridTemplateColumns:`max-content repeat(${a-2}, auto) max-content`},children:[Array.from({length:a},(m,p)=>p===0?O.jsx("div",{children:"Title"},`header_${p}`):p===a-1?O.jsx("div",{},`header_${p}`):O.jsxs("div",{children:["Selection ",p]},`header_${p}`)),t.rows.map((m,p)=>O.jsx(sp,{index:p,row:m,onChange:s,maxCols:a,groups:l},p))]}),O.jsxs("div",{className:"grid grid-cols-1 gap-4 p-4",children:[O.jsx("button",{onClick:o,children:"Add Row"}),O.jsx("button",{onClick:u,children:"Save Config"}),O.jsx("button",{onClick:i,children:"Clear Config"}),O.jsx("button",{onClick:e,children:"Close"})]})]})},vp=({open:e,onClose:t,children:n})=>{const r=Oe.useMemo(()=>document.createElement("div"),[]);return Oe.useEffect(()=>(r.setAttribute("data-addon-modal-root","true"),document.body.appendChild(r),()=>{document.body.removeChild(r)}),[r]),Oe.useEffect(()=>{if(!e)return;const l=o=>{o.key==="Escape"&&t()};return window.addEventListener("keydown",l,{capture:!0}),()=>window.removeEventListener("keydown",l,{capture:!0})},[e,t]),e?O.jsx("div",{role:"dialog","aria-modal":"true",onMouseDown:l=>{l.target===l.currentTarget&&t()},style:{position:"fixed",inset:0,display:"grid",placeItems:"center",background:"rgba(0,0,0,0.45)",zIndex:2147483647},children:O.jsx("div",{className:"p-dialog",onMouseDown:l=>l.stopPropagation(),children:O.jsx(mp,{onClose:()=>t()})})}):null},kc=0,gp=4,yp=2;function wp(e){if(!e||typeof e!="object")return null;const t=e;return t.rootGraph&&typeof t.rootGraph=="object"?t.rootGraph:t.graph&&typeof t.graph=="object"?t.graph:e}function Cc(e){return e._nodes??e.nodes??[]}function Sp(e){return e._groups??e.groups??[]}function kp(e){return e.isSubgraphNode()?e.subgraph:null}function Cp(e,t){const n=new Set,r=[e];for(;r.length;){const l=r.pop(),o=l;if(!n.has(o)){n.add(o),t(l);for(const u of Cc(l)){const i=kp(u);i&&r.push(i)}}}}const jn="__bv_prev_mode",Ec="__bv_bypass",ul="__bv_prev_mode_for_bypass";function Ep(e){const t=e.size;return Array.isArray(t)&&t.length>=2?[Number(t[0])||0,Number(t[1])||0]:[0,0]}function _p(e,t,n="overlap"){const r=t.pos??[0,0],l=t.size??[0,0],o=e.pos??[0,0],u=Ep(e),i=r[0],s=r[1],a=r[0]+l[0],m=r[1]+l[1],p=o[0],d=o[1],v=o[0]+u[0],S=o[1]+u[1];if(n==="topleft")return p>=i&&p<=a&&d>=s&&d<=m;if(n==="fully")return p>=i&&d>=s&&v<=a&&S<=m;const y=p<=a&&v>=i,P=d<=m&&S>=s;return y&&P}function xp(e,t){const n=wp(e);if(!n)return[];const r=[],l=t.trim();return Cp(n,o=>{for(const u of Sp(o))(u.title??"").trim()===l&&r.push({graph:o,group:u})}),r}function Np(e,t,n="overlap"){return Cc(e).filter(l=>_p(l,t,n))}function El(e,t,n){const l=xp(e,t).map(({graph:u,group:i})=>({graph:u,group:i,nodes:Np(u,i,n)})),o=l.reduce((u,i)=>u+i.nodes.length,0);return{groupTitle:t,affectedGroups:l.length,affectedNodes:o,perGroup:l}}function Pp(e,t,n="overlap"){const r=El(e,t,n);for(const l of r.perGroup)for(const o of l.nodes){const u=o;u.properties??(u.properties={}),typeof u.properties[jn]!="number"&&(u.properties[jn]=typeof u.mode=="number"?u.mode:0),u.mode=yp}return r}function zp(e,t,n="overlap"){const r=El(e,t,n);for(const l of r.perGroup)for(const o of l.nodes){const u=o,i=u.properties?.[jn];u.mode=typeof i=="number"?i:kc,u.properties&&jn in u.properties&&delete u.properties[jn]}return r}function Tp(e,t,n="overlap"){const r=El(e,t,n);for(const l of r.perGroup)for(const o of l.nodes){const u=o;u.properties??(u.properties={}),typeof u.properties[ul]!="number"&&(u.properties[ul]=typeof u.mode=="number"?u.mode:0),u.properties[Ec]=!0,u.mode=gp}return r}function Lp(e,t,n="overlap"){const r=El(e,t,n);for(const l of r.perGroup)for(const o of l.nodes){const u=o,i=u.properties?.[ul];u.mode=typeof i=="number"?i:kc,u.properties&&(delete u.properties[Ec],delete u.properties[ul])}return r}function _c(e){he().extensionManager.toast.add({severity:"error",summary:"Error",detail:e,life:5e3})}function Dt(e,t,n=!1){if(!t?.nodes)return;let r;return t.nodes.forEach(l=>{r||(l.id==e&&(n?(r=l,l.subgraph&&!l.type&&(r=l)):l.subgraph||(r=l)),!r&&l.isSubgraphNode&&(r=Dt(e,l.subgraph)))}),r}function xc(e){const t=[];return e.forEach(n=>{if(n.isSubgraphNode&&n.isSubgraphNode()){const r=n;t.push(...xc(r.subgraph.nodes))}else n.type==="BV Control Center"&&t.push(n)}),t}function Hu(e,t,n){let r=n;if(n||(r=e.inputs[t].label,r||(r=e.inputs[t].name)),!r)return!1;try{if(e.getInputLink(t)){if(e.graph?._subgraphs){const l=e.graph,o=e.getInputLink(t),u=o?.origin_id,i=o?.origin_slot;if(l.inputNode.id==u&&typeof i=="number"){const s=l.inputNode.allSlots[i];return l.renameInput(s,r),!0}return!1}return!0}}catch(l){return console.error("Error renaming subgraph input slot (LINK)",l),!1}return!1}function Rp(e){const t=[],n=r=>{for(const l of r??[])l?.isSubgraphNode?.()&&(t.push(l),l.subgraph?.nodes&&n(l.subgraph.nodes))};return n(e?.nodes),t}function Op(e,t){if(!e?.isSubgraphNode?.())return;const n=e.subgraph;if(!n)return;const r=n._nodes??n.nodes??[],l=o=>o?.type===t||o?.comfyClass===t||o?.title===t;return r.find(l)}function Qu(e){if(e.graph?.subgraphs){let t=function(r,l){let o;for(const u of r.nodes){if(o)return o;if(u.isSubgraphNode()){if(l==u.type)return u;if(o=t(u.subgraph,l),o)return o}}return o};const n=e.graph;return t(he().rootGraph,n.id)}}function Nc(e,t,n){const r=e?.constructor?.prototype;if(!r||r.__bv_patched)return!1;r.__bv_patched=!0;const l=r.onWidgetChanged;return r.onWidgetChanged=function(o,u,i,s){let a;try{a=l?.apply(this,arguments)}catch{}const m=he()?.rootGraph,p=Dt(this.id,m,!0),d=s,v=Op(p,"BV Control Center");return p&&v&&(console.debug("Subgraph container widget changed",o,u,i,s,p,s.label),v.widgets?.forEach(S=>{S.name==d.name&&(S.label=s.label)})),s?.label&&n(s.label,u),a},!0}function Mp(e,t,n){if(!e?.graph)return!1;const l=Qu(e);return l?Nc(l,t,n):!1}function Dp(e,t,n,r=20){let l=0;const o=()=>{console.debug("Retrying patching subgraph container from inner node",e.id,l),l++,!Mp(e,t,n)&&(l>=r||requestAnimationFrame(o))};o()}const Pc="BV Control Center";let bl=!1;function Ip(){bl||(bl=!0,setTimeout(()=>{bl=!1;const e=he()?.rootGraph;if(!e)return;const t=[],n=o=>{for(const u of o?.nodes??[])u&&(u.type===Pc&&t.push({node:u,graph:o}),u.isSubgraphNode?.()&&u.subgraph&&n(u.subgraph))};if(n(e),t.length<=1)return;t.sort((o,u)=>(o.node?.id??0)-(u.node?.id??0));const r=t[0].node;let l=!1;for(const o of t.slice(1)){const u=o.node,i=o.graph;if(u!==r){try{i.remove(u)}catch{const s=i.nodes?.indexOf(u);s>=0&&i.nodes.splice(s,1)}l=!0}}l&&(_c("BV Control Center is already in the workflow."),e.setDirtyCanvas?.(!0,!0))},0))}function jp(){const e=window.LGraph;if(!e?.prototype)return!1;const t=e.prototype;if(t.__bv_singleton_add_patched)return!0;t.__bv_singleton_add_patched=!0;const n=t.add?"add":t.addNode?"addNode":null;if(!n)return!1;const r=t[n];return t[n]=function(l){const o=r.apply(this,arguments);return l?.type===Pc&&Ip(),o},!0}const lt="ACTIVE",ot="MUTE/BYPASS",Fp=he();let ls=!1;function Up(){if(ls)return;ls=!0;let e=vt();e&&os(e),setInterval(()=>{const t=vt();t&&JSON.stringify(t)!==JSON.stringify(e)&&(e=t,os(e))},500)}function zc(e,t){let n=!1,r;if(e.subgraph.inputs.forEach(s=>{s.getLinks().forEach(a=>{n||(r=Dt(a.target_id,e.graph),!n&&r?.type=="BV Control Center"&&(n=!0))})}),!r)return;const l=vt();if(!l)return;let o=0;const u=200,i=()=>{Yo(r,l,void 0,!0)||o>=u||(o++,setTimeout(i,10))};i()}function Yo(e,t,n,r=!0,l){if(!e||!t)return!1;let o=!1;e.widgets||(e.widgets=[]);const u=n??e.widgets_values,i=t.rows.length;for(;e.inputs.lengthe.setDirtyCanvas(!0),{on:lt,off:ot});v.label=p,v.serialize=!0,e.inputs[a]._widget=v,o=!0}for(;e.inputs.length>i;){const a=e.inputs.length-1,m=`bvcc_${String(a).padStart(3,"0")}`,p=e.widgets?.findIndex(d=>d.name===m)??-1;p!==-1&&e.widgets.splice(p,1),e.isInputConnected(a)&&e.disconnectInput(a),e.removeInput(a),o=!0}let s=!0;for(let a=0;ay.name===p);if(d)d.options&&(d.options.on!==lt||d.options.off!==ot)&&(d.options.on=lt,d.options.off=ot,o=!0),r&&u&&u[a]!==void 0&&d.value!==u[a]&&(d.value=u[a],d.label=m,o=!0);else{const y=u&&u[a]!==void 0?u[a]:!0;d=e.addWidget("toggle",p,y,()=>e.setDirtyCanvas(!0),{on:lt,off:ot}),d.serialize=!0,d.label=m,o=!0}e.inputs[a]&&(e.inputs[a].widget={name:p},e.inputs[a]._widget=d);const v=Dt(e.id,e.graph);if(v){v.inputs[a];const y=v.getInputLink(a);y&&v.graph&&setTimeout(()=>{const P=Qu(v);if(P){const f=P.widgets;if(f){const c=f[y.origin_slot];c&&(c.label=m)}}},50)}if(Dt(e.id,e.graph)){let y=!1;y=Hu(e,a,m),y||(s=!1)}else s=!1}return o&&(e.serialize_widgets=!0,e.setDirtyCanvas(!0,!0),e.size=e.computeSize?.()??e.size),s}function os(e,t){const r=he().rootGraph;if(!r?.nodes||!e)return;xc(r.nodes).forEach(o=>{let u=!1;o.widgets||(o.widgets=[]);const i=o.widgets_values,s=Math.min(o.inputs.length,e.rows.length);for(let a=0;av.name===p);if(d)i&&i[a]!==void 0&&d.value!==i[a]&&(d.value=i[a],u=!0),d.options&&(d.options.on!==lt||d.options.off!==ot)&&(d.options.on=lt,d.options.off=ot,u=!0);else{const v=i&&i[a]!==void 0?i[a]:!0;d=o.addWidget("toggle",p,v,()=>o.setDirtyCanvas(!0),{on:lt,off:ot}),d.serialize=!0,u=!0}o.inputs[a].widget={name:p},o.inputs[a]._widget=d,Hu(o,a,m)}if(o.inputs.length>e.rows.length)for(let a=o.inputs.length-1;a>=e.rows.length;a--){const m=`bvcc_${String(a).padStart(3,"0")}`,p=o.widgets?.findIndex(d=>d.name===m)??-1;p!==-1&&o.widgets.splice(p,1),o.isInputConnected(a)&&o.disconnectInput(a),o.removeInput(a),u=!0}else if(o.inputs.lengtho.setDirtyCanvas(!0),{on:lt,off:ot});v.serialize=!0,o.inputs[a]._widget=v,u=!0}u&&(o.serialize_widgets=!0,o.setDirtyCanvas(!0,!0),o.size=o.computeSize())})}function Ap(e){if(e.__bv_onWidgetChanged_patched)return;e.__bv_onWidgetChanged_patched=!0;const t=e.onWidgetChanged;e.onWidgetChanged=function(n,r,l,o){let u;try{u=t?.apply(this,arguments)}catch{}if(vt()){const s=e.widgets.indexOf(o),a=s!==-1&&e.inputs[s]?e.inputs[s].label:o.label;if(!a)return;Gu(a,r)}return u}}function Vp(e){return e.ref.title}function Tc(e){return(e??"").toString().toLowerCase().trim()==="bypass"?"bypass":"mute"}function Bp(e,t,n){if(!e)return;const r=he();if(Tc(t)==="bypass"){n?Tp(r,e,"overlap"):Lp(r,e,"overlap");return}n?Pp(r,e,"overlap"):zp(r,e,"overlap")}function Gu(e,t){const n=vt();if(!n)return;const r=!t,l=n.rows.find(o=>o.title===e);if(l)for(const o of l.entries){const u=Vp(o),i=Tc(o.action);u&&Bp(u,i,r)}}Fp.registerExtension({name:"bv_control_center",async beforeRegisterNodeDef(e,t){const n=e.type,r=t.name;if(!(n==="BVControlCenterNode"||r==="BV Control Center"||r?.includes("BV Control Center")||r?.includes("🌀 BV Control Center")))return;const o=e.prototype.onNodeCreated;e.prototype.onNodeCreated=function(){const a=o?o.apply(this,arguments):void 0;this.serialize_widgets=!0;const m=vt();return m&&setTimeout(()=>{Yo(this,m)},50),Ap(this),Up(),a};const u=e.prototype.onConfigure;e.prototype.onConfigure=function(a){const m=u?u.apply(this,arguments):void 0;this.serialize_widgets=!0;const p=vt();return p&&Yo(this,p,a?.widgets_values),m};const i=e.prototype.onConnectInput;e.prototype.onConnectInput=function(a,m,p,d,v){const S=i?i.apply(this,arguments):void 0;if(this.serialize_widgets=!0,d.type&&!d.subgraph)return _c("BV Control Center can only be connected to a Subgraph."),!1;if(this.type=="BV Control Center"){let y=function(c=0){if(c>=f)return;const h=Dt(P,he().rootGraph);h&&Hu(h,a)||setTimeout(()=>{y(c+1)},50)};if(Dp(this,zc,Gu),typeof this.id!="number")return S;const P=this.id,f=20;return y(),S}};const s=e.prototype.onConnectionsChange;e.prototype.onConnectionsChange=function(a,m,p,d){const v=s?s.apply(this,arguments):void 0;if(!p&&a===1&&this.type=="BV Control Center"){console.debug("onConnectionsChange",a,d,p,m);const S=this.id,y=Dt(S,he().rootGraph,!0);if(y){const P=Qu(y);if(d&&P&&P.widgets){const f=P.widgets[d.origin_slot],c=f.value;P.removeWidget(f);const h=P.computeSize?.()??P.size;P.setSize?.(h),P.size=h,P.setDirtyCanvas?.(!0,!0),P.graph?.setDirtyCanvas?.(!0,!0);const g=y.getInputInfo(m);g!=null&&y.getWidgetFromSlot(g)&&function(E,N,U){const z=E?.widgets?.find(me=>me?.name===N);return z?(z.value=U,(z.callback||z.onChange)?.call(z,U,E,z),E.setSize?.(E.computeSize?.()??E.size),E.setDirtyCanvas?.(!0,!0),E.graph?.setDirtyCanvas?.(!0,!0),!0):!1}(P,f.name,c)}}}return v}}});function $p(){const e=he()?.rootGraph;if(!e)return 0;const t=Rp(e);let n=0;for(const r of t)Nc(r,zc,Gu)&&n++;return n}function Wp(e){const t=e?.intervalMs,n=e?.maxAttempts,r=e?.stopWhenPatched??!0;let l=0;const o=()=>{l++;const u=$p();r&&u>0||l>=n||setTimeout(o,t)};o()}Wp({intervalMs:150,maxAttempts:80});(function e(t=0){jp()||t>80||setTimeout(()=>e(t+1),50)})();const Hp=he();let Xo=null;function Qp(){const[e,t]=Oe.useState(!1);return Oe.useEffect(()=>(Xo=n=>t(n),()=>{Xo=null}),[]),O.jsx(vp,{open:e,onClose:()=>t(!1),children:"Hi"})}const us="bv-root";function is(){let e=document.getElementById(us);e||(e=document.createElement("div"),e.id=us,document.body.appendChild(e),eo.createRoot(e).render(O.jsx(Yc.StrictMode,{children:O.jsx(Qp,{})})))}Hp.registerExtension({name:"bv_nodepack.action_button",setup(){is()},actionBarButtons:[{icon:"pi pi-wrench",tooltip:"BV Tools Configuration (Beta)",onClick:()=>{is(),Xo?.(!0)}}]}); +`+o.stack}return{value:e,source:t,stack:l,digest:null}}function Gl(e,t,n){return{value:e,source:null,stack:n??null,digest:t??null}}function Ro(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var Ld=typeof WeakMap=="function"?WeakMap:Map;function Ba(e,t,n){n=We(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){br||(br=!0,Bo=r),Ro(e,t)},n}function $a(e,t,n){n=We(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var l=t.value;n.payload=function(){return r(l)},n.callback=function(){Ro(e,t)}}var o=e.stateNode;return o!==null&&typeof o.componentDidCatch=="function"&&(n.callback=function(){Ro(e,t),typeof r!="function"&&(at===null?at=new Set([this]):at.add(this));var u=t.stack;this.componentDidCatch(t.value,{componentStack:u!==null?u:""})}),n}function Ri(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Ld;var l=new Set;r.set(t,l)}else l=r.get(t),l===void 0&&(l=new Set,r.set(t,l));l.has(n)||(l.add(n),e=Wd.bind(null,e,t,n),t.then(e,e))}function Ii(e){do{var t;if((t=e.tag===13)&&(t=e.memoizedState,t=t!==null?t.dehydrated!==null:!0),t)return e;e=e.return}while(e!==null);return null}function Oi(e,t,n,r,l){return e.mode&1?(e.flags|=65536,e.lanes=l,e):(e===t?e.flags|=65536:(e.flags|=128,n.flags|=131072,n.flags&=-52805,n.tag===1&&(n.alternate===null?n.tag=17:(t=We(-1,1),t.tag=2,st(n,t,1))),n.lanes|=1),e)}var Rd=Ze.ReactCurrentOwner,ce=!1;function oe(e,t,n,r){t.child=e===null?ga(t,null,n,r):tn(t,e.child,n,r)}function ji(e,t,n,r,l){n=n.render;var o=t.ref;return Zt(t,l),r=Cu(e,t,n,r,o,l),n=xu(),e!==null&&!ce?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ye(e,t,l)):(U&&n&&fu(t),t.flags|=1,oe(e,t,r,l),t.child)}function Mi(e,t,n,r,l){if(e===null){var o=n.type;return typeof o=="function"&&!ju(o)&&o.defaultProps===void 0&&n.compare===null&&n.defaultProps===void 0?(t.tag=15,t.type=o,Ha(e,t,o,r,l)):(e=Ir(n.type,null,r,t,t.mode,l),e.ref=t.ref,e.return=t,t.child=e)}if(o=e.child,!(e.lanes&l)){var u=o.memoizedProps;if(n=n.compare,n=n!==null?n:Vn,n(u,r)&&e.ref===t.ref)return Ye(e,t,l)}return t.flags|=1,e=ft(o,r),e.ref=t.ref,e.return=t,t.child=e}function Ha(e,t,n,r,l){if(e!==null){var o=e.memoizedProps;if(Vn(o,r)&&e.ref===t.ref)if(ce=!1,t.pendingProps=r=o,(e.lanes&l)!==0)e.flags&131072&&(ce=!0);else return t.lanes=e.lanes,Ye(e,t,l)}return Io(e,t,n,r,l)}function Wa(e,t,n){var r=t.pendingProps,l=r.children,o=e!==null?e.memoizedState:null;if(r.mode==="hidden")if(!(t.mode&1))t.memoizedState={baseLanes:0,cachePool:null,transitions:null},M(Qt,me),me|=n;else{if(!(n&1073741824))return e=o!==null?o.baseLanes|n:n,t.lanes=t.childLanes=1073741824,t.memoizedState={baseLanes:e,cachePool:null,transitions:null},t.updateQueue=null,M(Qt,me),me|=e,null;t.memoizedState={baseLanes:0,cachePool:null,transitions:null},r=o!==null?o.baseLanes:n,M(Qt,me),me|=r}else o!==null?(r=o.baseLanes|n,t.memoizedState=null):r=n,M(Qt,me),me|=r;return oe(e,t,l,n),t.child}function Qa(e,t){var n=t.ref;(e===null&&n!==null||e!==null&&e.ref!==n)&&(t.flags|=512,t.flags|=2097152)}function Io(e,t,n,r,l){var o=de(n)?Nt:le.current;return o=bt(t,o),Zt(t,l),n=Cu(e,t,n,r,o,l),r=xu(),e!==null&&!ce?(t.updateQueue=e.updateQueue,t.flags&=-2053,e.lanes&=~l,Ye(e,t,l)):(U&&r&&fu(t),t.flags|=1,oe(e,t,n,l),t.child)}function Di(e,t,n,r,l){if(de(n)){var o=!0;Hr(t)}else o=!1;if(Zt(t,l),t.stateNode===null)Tr(e,t),Va(t,n,r),Lo(t,n,r,l),r=!0;else if(e===null){var u=t.stateNode,s=t.memoizedProps;u.props=s;var i=u.context,a=n.contextType;typeof a=="object"&&a!==null?a=xe(a):(a=de(n)?Nt:le.current,a=bt(t,a));var v=n.getDerivedStateFromProps,p=typeof v=="function"||typeof u.getSnapshotBeforeUpdate=="function";p||typeof u.UNSAFE_componentWillReceiveProps!="function"&&typeof u.componentWillReceiveProps!="function"||(s!==r||i!==a)&&Li(t,u,r,a),be=!1;var m=t.memoizedState;u.state=m,Yr(t,r,u,l),i=t.memoizedState,s!==r||m!==i||fe.current||be?(typeof v=="function"&&(To(t,n,v,r),i=t.memoizedState),(s=be||Ti(t,n,s,r,m,i,a))?(p||typeof u.UNSAFE_componentWillMount!="function"&&typeof u.componentWillMount!="function"||(typeof u.componentWillMount=="function"&&u.componentWillMount(),typeof u.UNSAFE_componentWillMount=="function"&&u.UNSAFE_componentWillMount()),typeof u.componentDidMount=="function"&&(t.flags|=4194308)):(typeof u.componentDidMount=="function"&&(t.flags|=4194308),t.memoizedProps=r,t.memoizedState=i),u.props=r,u.state=i,u.context=a,r=s):(typeof u.componentDidMount=="function"&&(t.flags|=4194308),r=!1)}else{u=t.stateNode,wa(e,t),s=t.memoizedProps,a=t.type===t.elementType?s:ze(t.type,s),u.props=a,p=t.pendingProps,m=u.context,i=n.contextType,typeof i=="object"&&i!==null?i=xe(i):(i=de(n)?Nt:le.current,i=bt(t,i));var y=n.getDerivedStateFromProps;(v=typeof y=="function"||typeof u.getSnapshotBeforeUpdate=="function")||typeof u.UNSAFE_componentWillReceiveProps!="function"&&typeof u.componentWillReceiveProps!="function"||(s!==p||m!==i)&&Li(t,u,r,i),be=!1,m=t.memoizedState,u.state=m,Yr(t,r,u,l);var w=t.memoizedState;s!==p||m!==w||fe.current||be?(typeof y=="function"&&(To(t,n,y,r),w=t.memoizedState),(a=be||Ti(t,n,a,r,m,w,i)||!1)?(v||typeof u.UNSAFE_componentWillUpdate!="function"&&typeof u.componentWillUpdate!="function"||(typeof u.componentWillUpdate=="function"&&u.componentWillUpdate(r,w,i),typeof u.UNSAFE_componentWillUpdate=="function"&&u.UNSAFE_componentWillUpdate(r,w,i)),typeof u.componentDidUpdate=="function"&&(t.flags|=4),typeof u.getSnapshotBeforeUpdate=="function"&&(t.flags|=1024)):(typeof u.componentDidUpdate!="function"||s===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof u.getSnapshotBeforeUpdate!="function"||s===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),t.memoizedProps=r,t.memoizedState=w),u.props=r,u.state=w,u.context=i,r=a):(typeof u.componentDidUpdate!="function"||s===e.memoizedProps&&m===e.memoizedState||(t.flags|=4),typeof u.getSnapshotBeforeUpdate!="function"||s===e.memoizedProps&&m===e.memoizedState||(t.flags|=1024),r=!1)}return Oo(e,t,n,r,o,l)}function Oo(e,t,n,r,l,o){Qa(e,t);var u=(t.flags&128)!==0;if(!r&&!u)return l&&Si(t,n,!1),Ye(e,t,o);r=t.stateNode,Rd.current=t;var s=u&&typeof n.getDerivedStateFromError!="function"?null:r.render();return t.flags|=1,e!==null&&u?(t.child=tn(t,e.child,null,o),t.child=tn(t,null,s,o)):oe(e,t,s,o),t.memoizedState=r.state,l&&Si(t,n,!0),t.child}function Ga(e){var t=e.stateNode;t.pendingContext?ki(e,t.pendingContext,t.pendingContext!==t.context):t.context&&ki(e,t.context,!1),wu(e,t.containerInfo)}function Fi(e,t,n,r,l){return en(),pu(l),t.flags|=256,oe(e,t,n,r),t.child}var jo={dehydrated:null,treeContext:null,retryLane:0};function Mo(e){return{baseLanes:e,cachePool:null,transitions:null}}function Ka(e,t,n){var r=t.pendingProps,l=A.current,o=!1,u=(t.flags&128)!==0,s;if((s=u)||(s=e!==null&&e.memoizedState===null?!1:(l&2)!==0),s?(o=!0,t.flags&=-129):(e===null||e.memoizedState!==null)&&(l|=1),M(A,l&1),e===null)return Po(t),e=t.memoizedState,e!==null&&(e=e.dehydrated,e!==null)?(t.mode&1?e.data==="$!"?t.lanes=8:t.lanes=1073741824:t.lanes=1,null):(u=r.children,e=r.fallback,o?(r=t.mode,o=t.child,u={mode:"hidden",children:u},!(r&1)&&o!==null?(o.childLanes=0,o.pendingProps=u):o=hl(u,r,0,null),e=_t(e,r,n,null),o.return=t,e.return=t,o.sibling=e,t.child=o,t.child.memoizedState=Mo(n),t.memoizedState=jo,e):Pu(t,u));if(l=e.memoizedState,l!==null&&(s=l.dehydrated,s!==null))return Id(e,t,u,r,s,l,n);if(o){o=r.fallback,u=t.mode,l=e.child,s=l.sibling;var i={mode:"hidden",children:r.children};return!(u&1)&&t.child!==l?(r=t.child,r.childLanes=0,r.pendingProps=i,t.deletions=null):(r=ft(l,i),r.subtreeFlags=l.subtreeFlags&14680064),s!==null?o=ft(s,o):(o=_t(o,u,n,null),o.flags|=2),o.return=t,r.return=t,r.sibling=o,t.child=r,r=o,o=t.child,u=e.child.memoizedState,u=u===null?Mo(n):{baseLanes:u.baseLanes|n,cachePool:null,transitions:u.transitions},o.memoizedState=u,o.childLanes=e.childLanes&~n,t.memoizedState=jo,r}return o=e.child,e=o.sibling,r=ft(o,{mode:"visible",children:r.children}),!(t.mode&1)&&(r.lanes=n),r.return=t,r.sibling=null,e!==null&&(n=t.deletions,n===null?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r}function Pu(e,t){return t=hl({mode:"visible",children:t},e.mode,0,null),t.return=e,e.child=t}function gr(e,t,n,r){return r!==null&&pu(r),tn(t,e.child,null,n),e=Pu(t,t.pendingProps.children),e.flags|=2,t.memoizedState=null,e}function Id(e,t,n,r,l,o,u){if(n)return t.flags&256?(t.flags&=-257,r=Gl(Error(g(422))),gr(e,t,u,r)):t.memoizedState!==null?(t.child=e.child,t.flags|=128,null):(o=r.fallback,l=t.mode,r=hl({mode:"visible",children:r.children},l,0,null),o=_t(o,l,u,null),o.flags|=2,r.return=t,o.return=t,r.sibling=o,t.child=r,t.mode&1&&tn(t,e.child,null,u),t.child.memoizedState=Mo(u),t.memoizedState=jo,o);if(!(t.mode&1))return gr(e,t,u,null);if(l.data==="$!"){if(r=l.nextSibling&&l.nextSibling.dataset,r)var s=r.dgst;return r=s,o=Error(g(419)),r=Gl(o,r,void 0),gr(e,t,u,r)}if(s=(u&e.childLanes)!==0,ce||s){if(r=J,r!==null){switch(u&-u){case 4:l=2;break;case 16:l=8;break;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:l=32;break;case 536870912:l=268435456;break;default:l=0}l=l&(r.suspendedLanes|u)?0:l,l!==0&&l!==o.retryLane&&(o.retryLane=l,Ke(e,l),Oe(r,e,l,-1))}return Ou(),r=Gl(Error(g(421))),gr(e,t,u,r)}return l.data==="$?"?(t.flags|=128,t.child=e.child,t=Qd.bind(null,e),l._reactRetry=t,null):(e=o.treeContext,ve=it(l.nextSibling),he=t,U=!0,Le=null,e!==null&&(ke[Se++]=$e,ke[Se++]=He,ke[Se++]=Pt,$e=e.id,He=e.overflow,Pt=t),t=Pu(t,r.children),t.flags|=4096,t)}function Ui(e,t,n){e.lanes|=t;var r=e.alternate;r!==null&&(r.lanes|=t),zo(e.return,t,n)}function Kl(e,t,n,r,l){var o=e.memoizedState;o===null?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:r,tail:n,tailMode:l}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=r,o.tail=n,o.tailMode=l)}function Ya(e,t,n){var r=t.pendingProps,l=r.revealOrder,o=r.tail;if(oe(e,t,r.children,n),r=A.current,r&2)r=r&1|2,t.flags|=128;else{if(e!==null&&e.flags&128)e:for(e=t.child;e!==null;){if(e.tag===13)e.memoizedState!==null&&Ui(e,n,t);else if(e.tag===19)Ui(e,n,t);else if(e.child!==null){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;e.sibling===null;){if(e.return===null||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}r&=1}if(M(A,r),!(t.mode&1))t.memoizedState=null;else switch(l){case"forwards":for(n=t.child,l=null;n!==null;)e=n.alternate,e!==null&&Xr(e)===null&&(l=n),n=n.sibling;n=l,n===null?(l=t.child,t.child=null):(l=n.sibling,n.sibling=null),Kl(t,!1,l,n,o);break;case"backwards":for(n=null,l=t.child,t.child=null;l!==null;){if(e=l.alternate,e!==null&&Xr(e)===null){t.child=l;break}e=l.sibling,l.sibling=n,n=l,l=e}Kl(t,!0,n,null,o);break;case"together":Kl(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Tr(e,t){!(t.mode&1)&&e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2)}function Ye(e,t,n){if(e!==null&&(t.dependencies=e.dependencies),Tt|=t.lanes,!(n&t.childLanes))return null;if(e!==null&&t.child!==e.child)throw Error(g(153));if(t.child!==null){for(e=t.child,n=ft(e,e.pendingProps),t.child=n,n.return=t;e.sibling!==null;)e=e.sibling,n=n.sibling=ft(e,e.pendingProps),n.return=t;n.sibling=null}return t.child}function Od(e,t,n){switch(t.tag){case 3:Ga(t),en();break;case 5:ka(t);break;case 1:de(t.type)&&Hr(t);break;case 4:wu(t,t.stateNode.containerInfo);break;case 10:var r=t.type._context,l=t.memoizedProps.value;M(Gr,r._currentValue),r._currentValue=l;break;case 13:if(r=t.memoizedState,r!==null)return r.dehydrated!==null?(M(A,A.current&1),t.flags|=128,null):n&t.child.childLanes?Ka(e,t,n):(M(A,A.current&1),e=Ye(e,t,n),e!==null?e.sibling:null);M(A,A.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&128){if(r)return Ya(e,t,n);t.flags|=128}if(l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null,l.lastEffect=null),M(A,A.current),r)break;return null;case 22:case 23:return t.lanes=0,Wa(e,t,n)}return Ye(e,t,n)}var Xa,Do,Za,Ja;Xa=function(e,t){for(var n=t.child;n!==null;){if(n.tag===5||n.tag===6)e.appendChild(n.stateNode);else if(n.tag!==4&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return}n.sibling.return=n.return,n=n.sibling}};Do=function(){};Za=function(e,t,n,r){var l=e.memoizedProps;if(l!==r){e=t.stateNode,Ct(Ae.current);var o=null;switch(n){case"input":l=ro(e,l),r=ro(e,r),o=[];break;case"select":l=B({},l,{value:void 0}),r=B({},r,{value:void 0}),o=[];break;case"textarea":l=uo(e,l),r=uo(e,r),o=[];break;default:typeof l.onClick!="function"&&typeof r.onClick=="function"&&(e.onclick=Br)}so(n,r);var u;n=null;for(a in l)if(!r.hasOwnProperty(a)&&l.hasOwnProperty(a)&&l[a]!=null)if(a==="style"){var s=l[a];for(u in s)s.hasOwnProperty(u)&&(n||(n={}),n[u]="")}else a!=="dangerouslySetInnerHTML"&&a!=="children"&&a!=="suppressContentEditableWarning"&&a!=="suppressHydrationWarning"&&a!=="autoFocus"&&(On.hasOwnProperty(a)?o||(o=[]):(o=o||[]).push(a,null));for(a in r){var i=r[a];if(s=l?.[a],r.hasOwnProperty(a)&&i!==s&&(i!=null||s!=null))if(a==="style")if(s){for(u in s)!s.hasOwnProperty(u)||i&&i.hasOwnProperty(u)||(n||(n={}),n[u]="");for(u in i)i.hasOwnProperty(u)&&s[u]!==i[u]&&(n||(n={}),n[u]=i[u])}else n||(o||(o=[]),o.push(a,n)),n=i;else a==="dangerouslySetInnerHTML"?(i=i?i.__html:void 0,s=s?s.__html:void 0,i!=null&&s!==i&&(o=o||[]).push(a,i)):a==="children"?typeof i!="string"&&typeof i!="number"||(o=o||[]).push(a,""+i):a!=="suppressContentEditableWarning"&&a!=="suppressHydrationWarning"&&(On.hasOwnProperty(a)?(i!=null&&a==="onScroll"&&D("scroll",e),o||s===i||(o=[])):(o=o||[]).push(a,i))}n&&(o=o||[]).push("style",n);var a=o;(t.updateQueue=a)&&(t.flags|=4)}};Ja=function(e,t,n,r){n!==r&&(t.flags|=4)};function gn(e,t){if(!U)switch(e.tailMode){case"hidden":t=e.tail;for(var n=null;t!==null;)t.alternate!==null&&(n=t),t=t.sibling;n===null?e.tail=null:n.sibling=null;break;case"collapsed":n=e.tail;for(var r=null;n!==null;)n.alternate!==null&&(r=n),n=n.sibling;r===null?t||e.tail===null?e.tail=null:e.tail.sibling=null:r.sibling=null}}function ne(e){var t=e.alternate!==null&&e.alternate.child===e.child,n=0,r=0;if(t)for(var l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags&14680064,r|=l.flags&14680064,l.return=e,l=l.sibling;else for(l=e.child;l!==null;)n|=l.lanes|l.childLanes,r|=l.subtreeFlags,r|=l.flags,l.return=e,l=l.sibling;return e.subtreeFlags|=r,e.childLanes=n,t}function jd(e,t,n){var r=t.pendingProps;switch(du(t),t.tag){case 2:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ne(t),null;case 1:return de(t.type)&&$r(),ne(t),null;case 3:return r=t.stateNode,nn(),F(fe),F(le),Su(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),(e===null||e.child===null)&&(vr(t)?t.flags|=4:e===null||e.memoizedState.isDehydrated&&!(t.flags&256)||(t.flags|=1024,Le!==null&&(Wo(Le),Le=null))),Do(e,t),ne(t),null;case 5:ku(t);var l=Ct(Qn.current);if(n=t.type,e!==null&&t.stateNode!=null)Za(e,t,n,r,l),e.ref!==t.ref&&(t.flags|=512,t.flags|=2097152);else{if(!r){if(t.stateNode===null)throw Error(g(166));return ne(t),null}if(e=Ct(Ae.current),vr(t)){r=t.stateNode,n=t.type;var o=t.memoizedProps;switch(r[Fe]=t,r[Hn]=o,e=(t.mode&1)!==0,n){case"dialog":D("cancel",r),D("close",r);break;case"iframe":case"object":case"embed":D("load",r);break;case"video":case"audio":for(l=0;l<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Fe]=t,e[Hn]=r,Xa(e,t,!1,!1),t.stateNode=e;e:{switch(u=ao(n,r),n){case"dialog":D("cancel",e),D("close",e),l=r;break;case"iframe":case"object":case"embed":D("load",e),l=r;break;case"video":case"audio":for(l=0;lln&&(t.flags|=128,r=!0,gn(o,!1),t.lanes=4194304)}else{if(!r)if(e=Xr(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),gn(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!U)return ne(t),null}else 2*Q()-o.renderingStartTime>ln&&n!==1073741824&&(t.flags|=128,r=!0,gn(o,!1),t.lanes=4194304);o.isBackwards?(u.sibling=t.child,t.child=u):(n=o.last,n!==null?n.sibling=u:t.child=u,o.last=u)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Q(),t.sibling=null,n=A.current,M(A,r?n&1|2:n&1),t):(ne(t),null);case 22:case 23:return Iu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?me&1073741824&&(ne(t),t.subtreeFlags&6&&(t.flags|=8192)):ne(t),null;case 24:return null;case 25:return null}throw Error(g(156,t.tag))}function Md(e,t){switch(du(t),t.tag){case 1:return de(t.type)&&$r(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return nn(),F(fe),F(le),Su(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return ku(t),null;case 13:if(F(A),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(g(340));en()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F(A),null;case 4:return nn(),null;case 10:return hu(t.type._context),null;case 22:case 23:return Iu(),null;case 24:return null;default:return null}}var yr=!1,re=!1,Dd=typeof WeakSet=="function"?WeakSet:Set,S=null;function Wt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){$(e,t,r)}else n.current=null}function Fo(e,t,n){try{n()}catch(r){$(e,t,r)}}var Ai=!1;function Fd(e,t){if(ko=Ur,e=na(),cu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var u=0,s=-1,i=-1,a=0,v=0,p=e,m=null;t:for(;;){for(var y;p!==n||l!==0&&p.nodeType!==3||(s=u+l),p!==o||r!==0&&p.nodeType!==3||(i=u+r),p.nodeType===3&&(u+=p.nodeValue.length),(y=p.firstChild)!==null;)m=p,p=y;for(;;){if(p===e)break t;if(m===n&&++a===l&&(s=u),m===o&&++v===r&&(i=u),(y=p.nextSibling)!==null)break;p=m,m=p.parentNode}p=y}n=s===-1||i===-1?null:{start:s,end:i}}else n=null}n=n||{start:0,end:0}}else n=null;for(So={focusedElem:e,selectionRange:n},Ur=!1,S=t;S!==null;)if(t=S,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,S=e;else for(;S!==null;){t=S;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var k=w.memoizedProps,j=w.memoizedState,f=t.stateNode,c=f.getSnapshotBeforeUpdate(t.elementType===t.type?k:ze(t.type,k),j);f.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(g(163))}}catch(h){$(t,t.return,h)}if(e=t.sibling,e!==null){e.return=t.return,S=e;break}S=t.return}return w=Ai,Ai=!1,w}function Ln(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Fo(t,n,o)}l=l.next}while(l!==r)}}function ml(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Uo(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function qa(e){var t=e.alternate;t!==null&&(e.alternate=null,qa(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Fe],delete t[Hn],delete t[xo],delete t[wd],delete t[kd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ba(e){return e.tag===5||e.tag===3||e.tag===4}function Vi(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ba(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ao(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Br));else if(r!==4&&(e=e.child,e!==null))for(Ao(e,t,n),e=e.sibling;e!==null;)Ao(e,t,n),e=e.sibling}function Vo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Vo(e,t,n),e=e.sibling;e!==null;)Vo(e,t,n),e=e.sibling}var q=null,Te=!1;function Je(e,t,n){for(n=n.child;n!==null;)ec(e,t,n),n=n.sibling}function ec(e,t,n){if(Ue&&typeof Ue.onCommitFiberUnmount=="function")try{Ue.onCommitFiberUnmount(ul,n)}catch{}switch(n.tag){case 5:re||Wt(n,t);case 6:var r=q,l=Te;q=null,Je(e,t,n),q=r,Te=l,q!==null&&(Te?(e=q,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):q.removeChild(n.stateNode));break;case 18:q!==null&&(Te?(e=q,n=n.stateNode,e.nodeType===8?Vl(e.parentNode,n):e.nodeType===1&&Vl(e,n),Un(e)):Vl(q,n.stateNode));break;case 4:r=q,l=Te,q=n.stateNode.containerInfo,Te=!0,Je(e,t,n),q=r,Te=l;break;case 0:case 11:case 14:case 15:if(!re&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&(o&2||o&4)&&Fo(n,t,u),l=l.next}while(l!==r)}Je(e,t,n);break;case 1:if(!re&&(Wt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(s){$(n,t,s)}Je(e,t,n);break;case 21:Je(e,t,n);break;case 22:n.mode&1?(re=(r=re)||n.memoizedState!==null,Je(e,t,n),re=r):Je(e,t,n);break;default:Je(e,t,n)}}function Bi(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Dd),t.forEach(function(r){var l=Gd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Pe(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=Q()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ad(r/1960))-r,10e?16:e,rt===null)var r=!1;else{if(e=rt,rt=null,el=0,I&6)throw Error(g(331));var l=I;for(I|=4,S=e.current;S!==null;){var o=S,u=o.child;if(S.flags&16){var s=o.deletions;if(s!==null){for(var i=0;iQ()-Lu?xt(e,0):Tu|=n),pe(e,t)}function sc(e,t){t===0&&(e.mode&1?(t=ar,ar<<=1,!(ar&130023424)&&(ar=4194304)):t=1);var n=ue();e=Ke(e,t),e!==null&&(qn(e,t,n),pe(e,n))}function Qd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),sc(e,n)}function Gd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(g(314))}r!==null&&r.delete(t),sc(e,n)}var ac;ac=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||fe.current)ce=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ce=!1,Od(e,t,n);ce=!!(e.flags&131072)}else ce=!1,U&&t.flags&1048576&&pa(t,Qr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Tr(e,t),e=t.pendingProps;var l=bt(t,le.current);Zt(t,n),l=Cu(null,t,r,e,l,n);var o=xu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,de(r)?(o=!0,Hr(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,yu(t),l.updater=pl,t.stateNode=l,l._reactInternals=t,Lo(t,r,e,n),t=Oo(null,t,r,!0,o,n)):(t.tag=0,U&&o&&fu(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Tr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Yd(r),e=ze(r,e),l){case 0:t=Io(null,t,r,e,n);break e;case 1:t=Di(null,t,r,e,n);break e;case 11:t=ji(null,t,r,e,n);break e;case 14:t=Mi(null,t,r,ze(r.type,e),n);break e}throw Error(g(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Io(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Di(e,t,r,l,n);case 3:e:{if(Ga(t),e===null)throw Error(g(387));r=t.pendingProps,o=t.memoizedState,l=o.element,wa(e,t),Yr(t,r,null,n);var u=t.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=rn(Error(g(423)),t),t=Fi(e,t,r,n,l);break e}else if(r!==l){l=rn(Error(g(424)),t),t=Fi(e,t,r,n,l);break e}else for(ve=it(t.stateNode.containerInfo.firstChild),he=t,U=!0,Le=null,n=ga(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(en(),r===l){t=Ye(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return ka(t),e===null&&Po(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,Eo(r,l)?u=null:o!==null&&Eo(r,o)&&(t.flags|=32),Qa(e,t),oe(e,t,u,n),t.child;case 6:return e===null&&Po(t),null;case 13:return Ka(e,t,n);case 4:return wu(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=tn(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),ji(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,u=l.value,M(Gr,r._currentValue),r._currentValue=u,o!==null)if(je(o.value,u)){if(o.children===l.children&&!fe.current){t=Ye(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var s=o.dependencies;if(s!==null){u=o.child;for(var i=s.firstContext;i!==null;){if(i.context===r){if(o.tag===1){i=We(-1,n&-n),i.tag=2;var a=o.updateQueue;if(a!==null){a=a.shared;var v=a.pending;v===null?i.next=i:(i.next=v.next,v.next=i),a.pending=i}}o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),zo(o.return,n,t),s.lanes|=n;break}i=i.next}}else if(o.tag===10)u=o.type===t.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(g(341));u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),zo(u,n,t),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===t){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Zt(t,n),l=xe(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=ze(r,t.pendingProps),l=ze(r.type,l),Mi(e,t,r,l,n);case 15:return Ha(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:ze(r,l),Tr(e,t),t.tag=1,de(r)?(e=!0,Hr(t)):e=!1,Zt(t,n),Va(t,r,l),Lo(t,r,l,n),Oo(null,t,r,!0,e,n);case 19:return Ya(e,t,n);case 22:return Wa(e,t,n)}throw Error(g(156,t.tag))};function cc(e,t){return Fs(e,t)}function Kd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ee(e,t,n,r){return new Kd(e,t,n,r)}function ju(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Yd(e){if(typeof e=="function")return ju(e)?1:0;if(e!=null){if(e=e.$$typeof,e===bo)return 11;if(e===eu)return 14}return 2}function ft(e,t){var n=e.alternate;return n===null?(n=Ee(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ir(e,t,n,r,l,o){var u=2;if(r=e,typeof e=="function")ju(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Mt:return _t(n.children,l,o,t);case qo:u=8,l|=8;break;case bl:return e=Ee(12,n,t,l|2),e.elementType=bl,e.lanes=o,e;case eo:return e=Ee(13,n,t,l),e.elementType=eo,e.lanes=o,e;case to:return e=Ee(19,n,t,l),e.elementType=to,e.lanes=o,e;case ks:return hl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ys:u=10;break e;case ws:u=9;break e;case bo:u=11;break e;case eu:u=14;break e;case qe:u=16,r=null;break e}throw Error(g(130,e==null?e:typeof e,""))}return t=Ee(u,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function _t(e,t,n,r){return e=Ee(7,e,r,t),e.lanes=n,e}function hl(e,t,n,r){return e=Ee(22,e,r,t),e.elementType=ks,e.lanes=n,e.stateNode={isHidden:!1},e}function Yl(e,t,n){return e=Ee(6,e,null,t),e.lanes=n,e}function Xl(e,t,n){return t=Ee(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Xd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Tl(0),this.expirationTimes=Tl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Tl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Mu(e,t,n,r,l,o,u,s,i){return e=new Xd(e,t,n,s,i),t===1?(t=1,o===!0&&(t|=8)):t=0,o=Ee(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},yu(o),e}function Zd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(mc)}catch(e){console.error(e)}}mc(),ms.exports=ye;var tp=ms.exports,Xi=tp;Jl.createRoot=Xi.createRoot,Jl.hydrateRoot=Xi.hydrateRoot;function Xe(){return Nc}function vc(e){if(!e||typeof e!="object")return null;const t=e,n=t.rootGraph;if(n&&typeof n=="object")return n;const r=t.graph;return r&&typeof r=="object"?r:e}function hc(e){return e._nodes??e.nodes??[]}function np(e){return e._groups??e.groups??[]}function rp(e){if(e.subgraph&&typeof e.subgraph=="object")return e.subgraph;if(typeof e.getSubgraph=="function")try{return e.getSubgraph()??null}catch{return null}return null}function gc(e,t){const n=new Set,r=[{graph:e,path:[]}];for(;r.length;){const{graph:l,path:o}=r.pop(),u=l;if(!n.has(u)){n.add(u),t(l,o);for(const s of hc(l)){const i=rp(s);i&&r.push({graph:i,path:o.concat([{node:s,graph:l,depth:o.length+1}])})}}}}function lp(){return globalThis.crypto?.randomUUID?.()??`bv-group-${Date.now()}-${Math.random().toString(16).slice(2)}`}function op(e,t){const n=e;n.flags??(n.flags={});let r=typeof n.flags.bvGroupId=="string"?n.flags.bvGroupId:"";if(!r||t.has(r)){do r=lp();while(t.has(r));n.flags.bvGroupId=r}return t.add(r),r}function rl(e){const t=vc(e);if(!t)return[];const n=[],r=new Set;return gc(t,(l,o)=>{for(const u of np(l)){const i=[...o.map(a=>String(a.node.title||a.node.type||a.node.id)),String(u.title||"Untitled Group")].join(" / ");n.push({group:u,graph:l,path:o,id:op(u,r),pathLabel:i})}}),n}function up(e,t){const n=vc(e);if(!n)return[];const r=[];return gc(n,(l,o)=>{for(const u of hc(l))yc(u,t)&&r.push({node:u,graph:l,path:o})}),r}function yc(e,t){const n=String(t);return[e.type,e.comfyClass,e.title,e.constructor?.name].filter(l=>typeof l=="string"&&l.length>0).map(String).includes(n)}const wc="bv-control-config-changed";function ip(){return{version:2,forceActive:!1,controls:[]}}function kc(e){var n;const t=Xe()?.rootGraph;t&&(t.extra??(t.extra={}),(n=t.extra).bv_settings??(n.bv_settings={}),t.extra.bv_settings.controlConfig=e,t.setDirtyCanvas?.(!0,!0),window.dispatchEvent(new CustomEvent(wc,{detail:e})))}function Zn(){const e=Xe()?.rootGraph?.extra?.bv_settings?.controlConfig;return e?.version===2?e:ip()}function sp(e,t=()=>!1){const n=e.group.nodes??e.group._nodes;if(Array.isArray(n)){const s=n.filter(i=>i&&typeof i=="object"&&!t(i));if(s.length>0)return s}const[r,l]=e.group.pos||[0,0],[o,u]=e.group.size||[0,0];return(e.graph._nodes??e.graph.nodes??[]).filter(s=>{if(t(s))return!1;const[i,a]=s.pos||[0,0],[v,p]=s.size||[0,0];return i+v/2>=r&&i+v/2<=r+o&&a+p/2>=l&&a+p/2<=l+u})}const Zi={activate:3,mute:2,bypass:1},Ji=e=>e.charAt(0).toUpperCase()+e.slice(1);function Sc(e){const t=new Map;for(const r of e.controls??[])if(r.enabled)for(const l of r.assignments??[]){if(l.unresolved)continue;const o=t.get(l.groupId)??[];o.push({controlId:r.id,controlName:r.name,action:l.action,groupId:l.groupId,groupPath:l.groupPath||l.groupTitle||l.groupId}),t.set(l.groupId,o)}const n=[];for(const r of t.values()){if(new Set(r.map(o=>o.action)).size<2)continue;const l=r.reduce((o,u)=>Zi[u.action]>Zi[o]?u.action:o,r[0].action);n.push({groupId:r[0].groupId,groupPath:r[0].groupPath,winnerAction:l,entries:r})}return n}function ap(e){if(!e.length)return"✓ No active conflicts";if(e.length>1)return`⚠ ${e.length} active control conflicts`;const t=e[0],n=[...new Set(t.entries.filter(r=>r.action!==t.winnerAction).map(r=>r.action))].join(", ");return`⚠ ${t.groupPath}: ${Ji(t.winnerAction)} overrides ${n.split(", ").map(Ji).join(", ")}`}const cp=({control:e,groups:t,conflicts:n,onChange:r})=>{const l=u=>u.charAt(0).toUpperCase()+u.slice(1),o=u=>{const s=t.find(i=>i.id===u);!s||e.assignments.some(i=>i.groupId===u)||r({...e,assignments:[...e.assignments,{groupId:u,groupPath:s.pathLabel,groupTitle:String(s.group.title||""),action:"bypass"}]})};return L.jsxs("section",{className:"bv-control-card",children:[L.jsxs("div",{className:"bv-control-title",children:[L.jsx("input",{value:e.name,onChange:u=>r({...e,name:u.target.value})}),L.jsxs("span",{children:[e.assignments.length," groups"]}),L.jsx("button",{onClick:()=>r(null),children:"Delete"})]}),L.jsx("div",{className:"bv-assignments",children:e.assignments.map((u,s)=>{const i=n.find(p=>p.groupId===u.groupId),a=i?.entries.filter(p=>p.action===i.winnerAction).map(p=>p.controlName).join(", "),v=i?u.action===i.winnerAction?`Overrides ${[...new Set(i.entries.filter(p=>p.action!==i.winnerAction).map(p=>l(p.action)))].join(", ")}`:`Overridden by ${l(i.winnerAction)} from ${a}`:"";return L.jsxs("div",{className:u.unresolved?"unresolved":i?"conflict":"",children:[L.jsxs("span",{children:[u.unresolved?"⚠ ":"",u.groupPath,v&&L.jsxs("small",{children:["⚠ ",v]})]}),L.jsxs("select",{value:u.action,onChange:p=>r({...e,assignments:e.assignments.map((m,y)=>y===s?{...m,action:p.target.value}:m)}),children:[L.jsx("option",{value:"activate",children:"Activate"}),L.jsx("option",{value:"mute",children:"Mute"}),L.jsx("option",{value:"bypass",children:"Bypass"})]}),L.jsx("button",{onClick:()=>r({...e,assignments:e.assignments.filter((p,m)=>m!==s)}),children:"×"})]},u.groupId)})}),L.jsxs("select",{value:"",onChange:u=>o(u.target.value),children:[L.jsx("option",{value:"",children:"Add group…"}),t.map(u=>L.jsx("option",{value:u.id,children:u.pathLabel},u.id))]})]})};function fp(){return globalThis.crypto?.randomUUID?.()??`bv-control-${Date.now()}-${Math.random().toString(16).slice(2)}`}const dp=({onClose:e})=>{const[t,n]=Re.useState(()=>{const i=Zn(),a=new Set(rl(Xe()).map(v=>v.id));return{...i,controls:i.controls.map(v=>({...v,assignments:v.assignments.map(p=>({...p,unresolved:!a.has(p.groupId)}))}))}}),r=Re.useMemo(()=>rl(Xe()),[t]),l=Re.useMemo(()=>Sc(t),[t]),o=(i,a)=>{n({...t,controls:a?t.controls.map((v,p)=>p===i?a:v):t.controls.filter((v,p)=>p!==i)})},u=()=>{let i="New Control",a=2;for(;t.controls.some(v=>v.name===i);)i=`New Control ${a++}`;n({...t,controls:[...t.controls,{id:fp(),name:i,enabled:!0,assignments:[]}]})},s=()=>{const i=t.controls.map(p=>p.name.trim());if(i.some(p=>!p)||new Set(i).size!==i.length)return;const a=new Set(r.map(p=>p.id)),v={...t,controls:t.controls.map(p=>({...p,name:p.name.trim(),assignments:p.assignments.map(m=>({...m,unresolved:!a.has(m.groupId)}))}))};kc(v),n(v)};return L.jsxs("div",{className:"bv-rack",children:[L.jsxs("header",{children:[L.jsxs("div",{children:[L.jsx("h2",{children:"BV Control Rack"}),L.jsx("p",{children:"User-defined workflow states"})]}),L.jsx("button",{onClick:e,children:"×"})]}),L.jsxs("label",{className:"bv-force",children:[L.jsx("input",{type:"checkbox",checked:t.forceActive,onChange:i=>n({...t,forceActive:i.target.checked})})," Force active after releasing restrictions"]}),L.jsx("div",{className:"bv-controls",children:t.controls.map((i,a)=>L.jsx(cp,{control:i,groups:r,conflicts:l,onChange:v=>o(a,v)},i.id))}),L.jsxs("footer",{children:[L.jsx("button",{onClick:u,children:"Add Control"}),L.jsx("button",{className:"primary",onClick:s,children:"Save Configuration"})]})]})},pp=({open:e,onClose:t})=>{const n=Re.useMemo(()=>document.createElement("div"),[]);return Re.useEffect(()=>(n.setAttribute("data-addon-modal-root","true"),document.body.appendChild(n),()=>{document.body.removeChild(n)}),[n]),Re.useEffect(()=>{if(!e)return;const r=l=>{l.key==="Escape"&&t()};return window.addEventListener("keydown",r,{capture:!0}),()=>window.removeEventListener("keydown",r,{capture:!0})},[e,t]),e?L.jsx("div",{className:"bv-modal",role:"dialog","aria-modal":"true",onMouseDown:r=>{r.target===r.currentTarget&&t()},children:L.jsx("div",{className:"bv-dialog",onMouseDown:r=>r.stopPropagation(),children:L.jsx(dp,{onClose:()=>t()})})}):null},mp=":root{--bv-primary-color: #3498db;--bv-secondary-color: #2c3e50;--bv-text-color: #ecf0f1;--bv-accent-color: #e74c3c;--bv-bg-color: #34495e;--bv-border-color: #7f8c8d;--bv-muted-text-color: #95a5a6}:root.comfy-theme-light #bv-root{--bv-primary-color: #2980b9;--bv-secondary-color: #34495e;--bv-text-color: #2c3e50;--bv-accent-color: #c0392b;--bv-bg-color: #ecf0f1;--bv-border-color: #bdc3c7;--bv-muted-text-color: #7f8c8d}.bv-modal{position:fixed;inset:0;z-index:2147483647;display:grid;place-items:center;padding:24px;background:#0009;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;color:var(--bv-text-color)}.bv-modal *{box-sizing:border-box}.bv-dialog{width:min(900px,92vw);max-height:84vh}.bv-rack{width:100%;max-height:84vh;overflow:auto;padding:20px;border:1px solid var(--bv-border-color);border-radius:14px;background:var(--comfy-menu-bg, #20242a);box-shadow:0 18px 60px #0009}.bv-rack header,.bv-rack footer,.bv-control-title,.bv-assignments>div{display:flex;align-items:center;gap:10px}.bv-rack header{justify-content:space-between;border-bottom:1px solid #ffffff20;margin-bottom:16px}.bv-rack h2{margin:0}.bv-rack p{margin-top:4px;color:var(--bv-muted-text-color)}.bv-force{display:flex;gap:8px;padding:10px 12px;background:#ffffff08;border-radius:8px}.bv-controls{display:grid;gap:12px;margin:16px 0}.bv-control-card{border:1px solid #ffffff20;border-radius:10px;padding:12px;background:#ffffff08}.bv-control-title input{flex:1;font-weight:650}.bv-control-title span{color:var(--bv-muted-text-color)}.bv-assignments{display:grid;gap:6px;margin:10px 0}.bv-assignments>div span{flex:1}.bv-assignments>div span small{display:block;margin-top:3px;color:#ffbd66}.bv-assignments .conflict{border-left:2px solid #ffbd66;padding-left:8px}.bv-assignments .unresolved{color:#ff9f7a}.bv-rack input,.bv-rack select,.bv-rack button{color:inherit;background:#15181c;border:1px solid #ffffff25;border-radius:6px;padding:7px 9px}.bv-rack button{cursor:pointer}.bv-rack button.primary{background:var(--bv-primary-color);border-color:transparent}.bv-rack footer{justify-content:flex-end}",Au="BV Control Center",ll=0,vp=2,hp=4,Cn="bvControlBaseMode",gp="bv-open-control-rack",Ec=320,yp=48,qi="bv_control_conflict_status",Zl=new Set;function wp(e){return e==="activate"?{mode:ll,priority:3}:e==="mute"?{mode:vp,priority:2}:{mode:hp,priority:1}}function bi(e){const t=new Set(rl(Xe()).map(n=>n.id));return{...e,controls:e.controls.map(n=>({...n,assignments:n.assignments.map(r=>({...r,unresolved:!t.has(r.groupId)}))}))}}function kp(e){return sp(e,t=>yc(t,Au))}function Sp(e){for(const r of Zl){const l=r.properties?.[Cn];r.mode=e.forceActive?ll:typeof l=="number"?l:r.mode,r.properties&&delete r.properties[Cn],r.setDirtyCanvas?.(!0,!0)}Zl.clear();const t=new Map(rl(Xe()).map(r=>[r.id,r])),n=new Map;for(const r of e.controls)if(r.enabled)for(const l of r.assignments){const o=t.get(l.groupId);if(o)for(const u of kp(o)){const s=wp(l.action),i=n.get(u);(!i||s.priority>i.priority)&&n.set(u,s)}}for(const[r,l]of n)r.properties??(r.properties={}),typeof r.properties[Cn]!="number"&&(r.properties[Cn]=typeof r.mode=="number"?r.mode:ll),r.mode=l.mode,Zl.add(r),r.setDirtyCanvas?.(!0,!0)}function Ep(e,t){const n=e.inputs[t],r=e.widgets?.find(l=>l.name===n.name);r&&e.removeWidget?.(r),n.link!=null&&e.disconnectInput(t),e.removeInput(t)}function Cp(e,t){const n=Sc(t),r=ap(n);let l=e.widgets?.find(o=>o.name===qi);l||(l=e.addCustomWidget({name:qi,type:"custom",value:r,y:0,options:{},serialize:!1,computeSize:()=>[0,24],computeLayoutSize:()=>({minHeight:24,maxHeight:24,minWidth:Ec}),mouse:()=>!1,draw(o,u,s,i,a){o.save(),o.beginPath(),o.rect(10,i,Math.max(0,s-20),a),o.clip(),o.fillStyle=this.__bvHasConflict?"#ffbd66":"#8fc9a3",o.font="12px sans-serif",o.textBaseline="middle",o.fillText(String(this.value??""),14,i+a/2),o.restore()}})),l.value=r,l.__bvHasConflict=n.length>0,l.tooltip=n.map(o=>`${o.groupPath}: ${o.winnerAction} wins`).join(` +`)}function Cc(e,t){e.mode=ll,e.properties&&delete e.properties[Cn];let n=e.widgets?.find(i=>i.name==="configure_control_center");n||(n=e.addWidget("button","configure_control_center",null,()=>window.dispatchEvent(new Event(gp)),{serialize:!1})),n.label="Configure Control Center",n.serialize=!1;const r=new Set(t.controls.map(i=>`bvcc_${i.id}`));for(let i=e.inputs.length-1;i>=0;i--){const a=e.inputs[i];a.name.startsWith("bvcc_")&&!r.has(a.name)&&Ep(e,i)}for(const i of t.controls){const a=`bvcc_${i.id}`,v=y=>{const w=Zn(),k={...w,controls:w.controls.map(j=>j.id===i.id?{...j,enabled:!!y}:j)};kc(k)};let p=e.inputs.find(y=>y.name===a);p||(p=e.addInput(a,"BOOLEAN",{label:i.name,nameLocked:!0,widget:{name:a}})),p.label=i.name;let m=e.widgets?.find(y=>y.name===a);m&&!m.__bvControlCallbackBound&&(e.removeWidget?.(m),m=null),m||(m=e.addWidget("toggle",a,i.enabled,v,{on:"ACTIVE",off:"INACTIVE"})),m.__bvControlCallbackBound=!0,m.label=i.name,m.value=i.enabled,m.serialize=!0,m.options??(m.options={}),m.options.on="ACTIVE",m.options.off="INACTIVE",m.callback=v,p.widget={name:a}}Cp(e,t);const l=e.widgets?.find(i=>i.name==="bv_control_config_json");l&&(l.value=JSON.stringify(bi(t)),l.type="converted-widget",l.draw=()=>{},l.hidden=!0,l.options??(l.options={}),l.options.hidden=!0,l.element&&(l.element.style.display="none"),l.computeSize=()=>[0,-4],l.serializeValue=()=>JSON.stringify(bi(Zn()))),e.serialize_widgets=!0;const o=e.size??[0,0],u=e.computeSize?.()??o,s=Math.max(Ec,(u[0]??0)+yp);e.setSize?.([Math.max(o[0]??0,s),u[1]??o[1]??0]),e.setDirtyCanvas?.(!0,!0)}function xc(e=Zn()){for(const{node:t}of up(Xe(),Au))Cc(t,e);Sp(e)}window.addEventListener(wc,e=>xc(e.detail));Xe().registerExtension({name:"bv_nodepack.control_center",async beforeRegisterNodeDef(e,t){if(t.name!==Au)return;const n=e.prototype.onNodeCreated;e.prototype.onNodeCreated=function(){const r=n?.apply(this,arguments);return Cc(this,Zn()),r}},afterConfigureGraph(){xc()}});const xp=Xe(),es="bv-open-control-rack",ts="bv-nodepack-styles";function _p(){const[e,t]=Re.useState(!1);return Re.useEffect(()=>{const n=()=>t(!0);return window.addEventListener(es,n),()=>window.removeEventListener(es,n)},[]),L.jsx(pp,{open:e,onClose:()=>t(!1),children:"Hi"})}const ns="bv-root";function Np(){if(!document.getElementById(ts)){const t=document.createElement("style");t.id=ts,t.textContent=mp,document.head.appendChild(t)}let e=document.getElementById(ns);e||(e=document.createElement("div"),e.id=ns,document.body.appendChild(e),Jl.createRoot(e).render(L.jsx(Hc.StrictMode,{children:L.jsx(_p,{})})))}xp.registerExtension({name:"bv_nodepack.control_rack_portal",setup(){Np()}}); diff --git a/js/bv_pipe_watch.js b/js/bv_pipe_watch.js deleted file mode 100644 index a75835b..0000000 --- a/js/bv_pipe_watch.js +++ /dev/null @@ -1,46 +0,0 @@ -// bv_pipe_watch.js -import { app } from "../../scripts/app.js"; -import { defer, refreshAllPipesEverywhere, isGraphLike } from "./bv_pipe_shared.js"; - -app.registerExtension({ - name: "bv_nodepack.bv_pipe_watch", - async setup() { - const lastVer = new WeakMap(); - let lastGraph = null; - - setInterval(() => { - const g = app.graph; - if (!g) return; - - // On graph switch (enter/exit subgraph) do a global refresh once - if (g !== lastGraph) { - lastGraph = g; - defer(() => refreshAllPipesEverywhere(g)); - } - - // Detect link changes even if onChange hooks are not reliable - const v = typeof g._version === "number" ? g._version : null; - const prev = lastVer.get(g); - - if (v != null && prev !== v) { - lastVer.set(g, v); - defer(() => refreshAllPipesEverywhere(g)); - } else if (v == null && prev == null) { - // fallback if _version not present - lastVer.set(g, 1); - defer(() => refreshAllPipesEverywhere(g)); - } - - // Optional: if some builds store a root pointer, refresh that too - const root = g?._root; - if (root && isGraphLike(root)) { - const vr = typeof root._version === "number" ? root._version : null; - const pr = lastVer.get(root); - if (vr != null && pr !== vr) { - lastVer.set(root, vr); - defer(() => refreshAllPipesEverywhere(root)); - } - } - }, 250); - }, -}); diff --git a/js/bv_prompt_debug.js b/js/bv_prompt_debug.js index e9dc127..b92ea24 100644 --- a/js/bv_prompt_debug.js +++ b/js/bv_prompt_debug.js @@ -1,81 +1,72 @@ import { app } from "../../scripts/app.js"; -function setWidgetReadonly(widget, readonly = true) { - // Keep it interactive (scroll/select), but prevent edits - widget.disabled = false; - widget.readonly = readonly; - widget.options = widget.options || {}; - widget.options.readonly = readonly; +const WIDGET_NAME = "bv_ast_debug_output"; - // If the DOM element exists (textarea/input), set readonly there too - const el = widget.inputEl || widget.element || widget.el; - if (el) { - el.readOnly = readonly; - el.setAttribute("readonly", readonly ? "true" : "false"); - // Important: do NOT disable pointer events, otherwise scrolling breaks - el.style.pointerEvents = "auto"; - } +function ensureDebugWidget(node) { + if (!node || node.__bvAstDebugWidget) return node?.__bvAstDebugWidget; - // Some ComfyUI widget implementations use callback to commit value; - // we can keep callback but readonly prevents edits anyway. + const textarea = document.createElement("textarea"); + textarea.readOnly = true; + textarea.placeholder = "Run the workflow to inspect the AST."; + textarea.style.boxSizing = "border-box"; + textarea.style.width = "100%"; + textarea.style.height = "100%"; + textarea.style.minHeight = "160px"; + textarea.style.resize = "vertical"; + textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Consolas, monospace"; + textarea.style.fontSize = "12px"; + textarea.style.whiteSpace = "pre"; + + const widget = node.addDOMWidget(WIDGET_NAME, "textarea", textarea, { + serialize: false, + getMinHeight: () => 160, + getValue: () => textarea.value, + setValue: (value) => { + textarea.value = typeof value === "string" ? value : ""; + }, + }); + + widget.element = textarea; + node.__bvAstDebugWidget = widget; + return widget; +} + +function setDebugText(node, value) { + const widget = ensureDebugWidget(node); + const textarea = widget?.element; + if (!textarea) return; + + textarea.value = value; + node.setDirtyCanvas?.(true, true); + node.graph?.setDirtyCanvas?.(true, true); } app.registerExtension({ name: "bv_nodepack.ast_debug_output", async beforeRegisterNodeDef(nodeType, nodeData) { - const name = nodeData?.name; - if (name !== "BV Prompt AST Debug") return; + if (nodeData?.name !== "BV Prompt AST Debug") return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated?.apply(this, arguments); + const result = onNodeCreated?.apply(this, arguments); + ensureDebugWidget(this); + return result; + }; - // Find by widget name (much more stable than slot index) - const w = this.widgets?.find((x) => x.name === "debug_output"); - if (w) { - // Mark readonly immediately (if DOM exists already) - setWidgetReadonly(w, true); - - // Fallback: ensure readonly once the input element exists - const origDraw = w.draw; - w.draw = function () { - const rr = origDraw?.apply(this, arguments); - if (!w.__bv_readonly_applied) { - setWidgetReadonly(w, true); - // if element exists now, lock it - const el = w.inputEl || w.element || w.el; - if (el) w.__bv_readonly_applied = true; - } - return rr; - }; - } - - return r; + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = onConfigure?.apply(this, arguments); + ensureDebugWidget(this); + return result; }; const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { - const r = onExecuted?.apply(this, arguments); - - // Your python returns UI key "ast_json": [txt] :contentReference[oaicite:1]{index=1} - const txt = message?.ast_json?.[0]; - if (typeof txt === "string") { - const w = this.widgets?.find((x) => x.name === "debug_output"); - if (w) { - w.value = txt; - - // Keep readonly and refresh element value if present - setWidgetReadonly(w, true); - const el = w.inputEl || w.element || w.el; - if (el) el.value = txt; - - this.setDirtyCanvas(true, true); - this.graph?.setDirtyCanvas(true, true); - } - } - - return r; + const result = onExecuted?.apply(this, arguments); + const text = message?.ast_json?.[0]; + if (typeof text === "string") setDebugText(this, text); + return result; }; - } + }, }); diff --git a/js/bv_smart_pipe.js b/js/bv_smart_pipe.js new file mode 100644 index 0000000..4446e08 --- /dev/null +++ b/js/bv_smart_pipe.js @@ -0,0 +1,1113 @@ +import { app } from "../../scripts/app.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 { + HOST_ROUTING_PROPERTY, + cloneRouteRegistryPrefix, + compareRoutingNumbers, + crossScopeDescriptors, + detachedHostRouting, + executionScope, + logicalAddress, + materializeAddressedPipeLinks, + materializeSmartPipeMergeSources, + materializeWirelessPipeLinks, + preferredHostRoutingName, + predecessorChoiceRoutes, + promptModeState, + prunePromptBranches, + reconcileRouteRegistryDestinations, + ROOT_ROUTING_PROPERTY, + remapPromptOutputLinks, + ROUTE_INPUT, + ROUTE_REGISTRY_PROPERTY, + routingChoiceLabel, + routingDisplayNumbers, + routingCollisionOwner, + uniqueHostName, + uniqueRoutingName, + validateMaterializedPipeGraph, +} from "./bv_smart_pipe_routing.js"; + +const NODE_CLASS = "BV Smart Pipe"; +const MAX_SLOTS = 100; +const ADD_SLOT_NAME = "bv_add_slot"; +const activeGraphContexts = new WeakMap(); + +export function isSmartPipe(node) { + return node?.comfyClass === NODE_CLASS || node?.type === NODE_CLASS; +} + +function stateFor(node) { + node.properties ??= {}; + node.properties.bvSmartPipe ??= { version: 1, localSlots: [], inheritedSlots: [] }; + const state = node.properties.bvSmartPipe; + if (!state.nextOrdinal) { + const slots = [...(state.inheritedSlots || []), ...(state.localSlots || []), ...(state.resolvedSlots || [])]; + state.nextOrdinal = Math.max(0, ...slots.map((slot) => slot.ordinal || 0)) + 1; + } + return state; +} + +function newId() { + return globalThis.crypto?.randomUUID?.() ?? `bv-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function pipeNodes(graph) { + return (graph?._nodes || graph?.nodes || []).filter(isSmartPipe); +} + +function propagateGraph(graph) { + pipeNodes(graph).forEach((candidate) => propagate(candidate)); +} + +function subgraphFor(node) { + return node?.subgraph || node?.getSubgraph?.() || null; +} + +function graphNodes(graph) { + return graph?._nodes || graph?.nodes || []; +} + +function containsSmartPipe(graph, visited = new Set()) { + if (!graph || visited.has(graph)) return false; + visited.add(graph); + return graphNodes(graph).some((node) => isSmartPipe(node) || isSmartPipeMerge(node) || containsSmartPipe(subgraphFor(node), visited)); +} + +function rootRoutingState(graph) { + graph.extra ??= {}; + graph.extra[ROOT_ROUTING_PROPERTY] ??= newId(); + graph.extra[ROUTE_REGISTRY_PROPERTY] ??= {}; + return { + rootId: graph.extra[ROOT_ROUTING_PROPERTY], + registry: graph.extra[ROUTE_REGISTRY_PROPERTY], + }; +} + +function ensureHostRouting(host, usedIds, usedNames) { + host.properties ??= {}; + const existing = detachedHostRouting(host.properties[HOST_ROUTING_PROPERTY]); + host.properties[HOST_ROUTING_PROPERTY] = existing; + let replacedId = null; + if (!existing.id || usedIds.has(existing.id)) { + replacedId = existing.id || null; + existing.id = newId(); + } + const definitionName = subgraphFor(host)?.name; + const requestedName = preferredHostRoutingName({ + storedName: existing.name, + storedDefinitionName: existing.definitionName, + title: host.title, + lastAppliedTitle: host.__bvLastHostRoutingTitle, + definitionName, + }); + existing.name = uniqueHostName(requestedName, usedNames); + if (definitionName) existing.definitionName = definitionName; + host.title = existing.name; + host.__bvLastHostRoutingTitle = existing.name; + usedIds.add(existing.id); + usedNames.add(existing.name); + return { routing: existing, replacedId }; +} + +function isSmartPipeMerge(node) { + return node?.comfyClass === "BV Smart Pipe Merge" || node?.type === "BV Smart Pipe Merge"; +} + +function routableNodeId(node) { + if (isSmartPipe(node)) return routingFor(node).nodeId; + node.properties ??= {}; + node.properties.bvSmartPipeMerge ??= { version: 1, nodeId: newId(), sources: [] }; + const state = node.properties.bvSmartPipeMerge; + if (!state.nodeId) state.nodeId = newId(); + return state.nodeId; +} + +export function collectExpandedPipeAddresses(rootGraph) { + const { rootId, registry } = rootRoutingState(rootGraph); + const addressByExecutionId = {}; + const descriptors = []; + const walk = (graph, numericPath = [], hostPath = [], hostNames = []) => { + const participatingHosts = graphNodes(graph).filter((node) => containsSmartPipe(subgraphFor(node))); + const usedHostIds = new Set(); + const usedHostNames = new Set(); + const seenDefinitions = new Set(); + for (const node of graphNodes(graph)) { + const executionId = [...numericPath, node.id].join(":"); + if (isSmartPipe(node) || isSmartPipeMerge(node)) { + const route = isSmartPipe(node) ? routingFor(node) : { nodeId: routableNodeId(node), name: node.title || "Pipe Merge" }; + const address = logicalAddress(rootId, hostPath, route.nodeId); + addressByExecutionId[executionId] = address; + descriptors.push({ address, executionId, node, route, kind: isSmartPipeMerge(node) ? "merge" : "pipe", hostPath: [...hostPath], hostNames: [...hostNames] }); + } + const subgraph = subgraphFor(node); + if (!subgraph || !participatingHosts.includes(node)) continue; + const definitionAlreadySeen = seenDefinitions.has(subgraph); + seenDefinitions.add(subgraph); + const { routing: host, replacedId } = ensureHostRouting(node, usedHostIds, usedHostNames); + if (!definitionAlreadySeen && subgraph.name && subgraph.name !== host.name) { + subgraph.name = host.name; + host.definitionName = host.name; + } + if (replacedId) { + cloneRouteRegistryPrefix( + registry, + logicalAddress(rootId, hostPath, replacedId), + logicalAddress(rootId, hostPath, host.id), + ); + } + walk(subgraph, [...numericPath, node.id], [...hostPath, host.id], [...hostNames, host.name]); + } + }; + walk(rootGraph); + return { addressByExecutionId, descriptors, registry, rootId }; +} + +function applyInstanceSchemaProjections(apiPrompt, routing) { + for (const descriptor of routing.descriptors) { + if (!isSmartPipe(descriptor.node)) continue; + const projection = routing.registry[descriptor.address]?.projection; + if (!projection?.resolvedSlots?.length) continue; + const entry = apiPrompt?.[descriptor.executionId]; + if (!entry || entry.class_type !== NODE_CLASS) continue; + entry.inputs.bv_smart_pipe_schema_json = JSON.stringify(projection.resolvedSlots.map((slot) => { + const input = descriptor.node.inputs?.find((item) => item.bvSlotId === slot.id); + const output = descriptor.node.outputs?.find((item) => item.bvSlotId === slot.id); + return { ...slot, connected: input?.link != null || Boolean(output?.links?.length) }; + })); + } + return apiPrompt; +} + +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; + } + outputIndexMaps[descriptor.executionId] = indexMap; + } + return remapPromptOutputLinks(apiPrompt, outputIndexMaps); +} + +function samePath(left, right) { + return left?.length === right?.length && left.every((part, index) => part === right[index]); +} + +export function activeDescriptorFor(node, routing = collectExpandedPipeAddresses(app.graph)) { + const hostPath = node.graph === app.graph ? [] : activeGraphContexts.get(node.graph)?.hostPath; + if (hostPath) return routing.descriptors.find((descriptor) => descriptor.node === node && samePath(descriptor.hostPath, hostPath)) || null; + const candidates = routing.descriptors.filter((descriptor) => descriptor.node === node); + return candidates.length === 1 ? candidates[0] : null; +} + +function applyMergeConfigurations(apiPrompt, routing) { + for (const descriptor of routing.descriptors) { + if (descriptor.kind !== "merge") continue; + const entry = apiPrompt?.[descriptor.executionId]; + if (!entry || entry.class_type !== "BV Smart Pipe Merge") continue; + const sources = routing.registry[descriptor.address]?.sources || descriptor.node.properties?.bvSmartPipeMerge?.sources || []; + entry.inputs.bv_smart_pipe_merge_json = JSON.stringify(sources); + } +} + +function installSubgraphContextTracking() { + if (app.__bvSmartPipeContextTracking || !app.canvas?.addEventListener) return; + app.__bvSmartPipeContextTracking = true; + if (app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] }); + app.canvas.addEventListener("subgraph-opened", (event) => { + const detail = event.detail || event; + const parent = detail.closingGraph === app.graph + ? { hostPath: [], hostNames: [] } + : activeGraphContexts.get(detail.closingGraph); + if (!parent || !detail.fromNode || !detail.subgraph) return; + collectExpandedPipeAddresses(app.graph); + const host = detail.fromNode.properties?.[HOST_ROUTING_PROPERTY]; + if (!host) return; + activeGraphContexts.set(detail.subgraph, { + hostPath: [...parent.hostPath, host.id], + hostNames: [...parent.hostNames, host.name], + }); + requestAnimationFrame(() => propagateGraph(detail.subgraph)); + }); + app.canvas.addEventListener("litegraph:set-graph", (event) => { + const detail = event.detail || event; + if (detail.newGraph === app.graph) activeGraphContexts.set(app.graph, { hostPath: [], hostNames: [] }); + requestAnimationFrame(() => propagateGraph(detail.newGraph)); + }); +} + +function routingFor(node) { + const state = stateFor(node); + state.routing ??= { version: 1, nodeId: newId(), name: !node.title || node.title === "Pipe" ? SMART_PIPE_DEFAULT_TITLE : node.title, mode: "root", predecessorId: null }; + const route = state.routing; + const siblings = pipeNodes(node.graph).filter((candidate) => candidate !== node); + const duplicateIdOwner = siblings.find((candidate) => stateFor(candidate).routing?.nodeId === route.nodeId); + if (duplicateIdOwner && routingCollisionOwner(node, duplicateIdOwner) !== node) route.nodeId = newId(); + const requestedName = node.__bvLastRoutingTitle && node.title !== node.__bvLastRoutingTitle ? node.title : route.name; + const usedNames = new Set(siblings + .filter((candidate) => { + const siblingName = stateFor(candidate).routing?.name; + return siblingName !== requestedName || routingCollisionOwner(node, candidate) !== node; + }) + .map((candidate) => stateFor(candidate).routing?.name) + .filter(Boolean)); + route.name = uniqueRoutingName(requestedName, usedNames); + node.title = route.name; + node.__bvLastRoutingTitle = route.name; + if (!route.predecessorId) route.mode = "root"; + return route; +} + +function linkAt(graph, id) { + return graph?.links?.get?.(id) ?? graph?.links?.[id]; +} + +function upstreamNode(node) { + const input = node.inputs?.find((slot) => slot.name === "pipe"); + if (input?.link == null) return null; + const link = linkAt(node.graph, input.link); + return link ? node.graph?.getNodeById?.(link.origin_id) : null; +} + +function wirelessUpstreamNode(node) { + const route = routingFor(node); + if (route.mode !== "follow" || !route.predecessorId) return null; + return pipeNodes(node.graph).find((candidate) => routingFor(candidate).nodeId === route.predecessorId) || null; +} + +function crossScopeRouteFor(node, routing = collectExpandedPipeAddresses(app.graph), descriptorOverride = null) { + const destination = descriptorOverride || activeDescriptorFor(node, routing); + if (!destination) return null; + const edge = routing.registry[destination.address]; + if (!edge?.predecessorAddress) return null; + const predecessor = routing.descriptors.find((descriptor) => descriptor.address === edge.predecessorAddress); + return predecessor ? { destination, predecessor, edge, routing } : null; +} + +function crossScopeUpstreamNode(node) { + return crossScopeRouteFor(node)?.predecessor?.node || null; +} + +function effectiveUpstreamNode(node) { + return upstreamNode(node) || crossScopeUpstreamNode(node) || wirelessUpstreamNode(node); +} + +function registerUpstream(node, upstream) { + if (node.__bvSmartPipeUpstream === upstream) return; + node.__bvSmartPipeUpstream?.__bvSmartPipeTargets?.delete(node); + node.__bvSmartPipeUpstream = upstream; + if (!upstream) return; + upstream.__bvSmartPipeTargets ??= new Set(); + upstream.__bvSmartPipeTargets.add(node); +} + +function copySlot(slot, missing = slot.missing ?? false) { + return { + id: slot.id, + name: slot.name, + ordinal: slot.ordinal, + type: slot.type || "*", + showInput: slot.showInput ?? false, + showOutput: slot.showOutput ?? false, + missing, + dormant: slot.dormant ?? false, + }; +} + +function slotHasConnections(node, slotId) { + const input = node.inputs?.find((item) => item.bvSlotId === slotId); + const output = node.outputs?.find((item) => item.bvSlotId === slotId); + return input?.link != null || Boolean(output?.links?.length); +} + +function slotHasInputConnection(node, slotId) { + return node.inputs?.some((item) => item.bvSlotId === slotId && item.link != null) ?? false; +} + +function downstreamPipeNodes(node) { + return pipeNodes(node.graph).filter((candidate) => candidate !== node && effectiveUpstreamNode(candidate) === node); +} + +function slotUsedDownstream(node, slotId, visited = new Set()) { + if (!node || visited.has(node)) return false; + visited.add(node); + for (const target of downstreamPipeNodes(node)) { + if (slotHasConnections(target, slotId) || slotUsedDownstream(target, slotId, visited)) return true; + } + return false; +} + +function schemaProjectionFor(node, descriptorOverride = null, routing = collectExpandedPipeAddresses(app.graph)) { + const definition = stateFor(node); + const crossRoute = crossScopeRouteFor(node, routing, descriptorOverride); + if (!crossRoute) return { definition, projection: definition }; + crossRoute.edge.projection ??= { version: 1, inheritedSlots: [], resolvedSlots: [] }; + return { definition, projection: crossRoute.edge.projection }; +} + +function descriptorForWiredMergeInput(mergeDescriptor, input, routing) { + if (input?.link == null) return null; + const link = linkAt(mergeDescriptor.node.graph, input.link); + const sourceNode = link ? mergeDescriptor.node.graph?.getNodeById?.(link.origin_id) : null; + return routing.descriptors.find((candidate) => candidate.node === sourceNode && samePath(candidate.hostPath, mergeDescriptor.hostPath)) || null; +} + +function resolveDescriptorSchema(descriptor, resolving, routing) { + if (!descriptor) return null; + if (descriptor.kind === "pipe") return resolveSchema(descriptor.node, resolving, descriptor); + const resolvingKey = descriptor.address; + if (resolving.has(resolvingKey)) return []; + resolving.add(resolvingKey); + const sources = routing.registry[descriptor.address]?.sources || descriptor.node.properties?.bvSmartPipeMerge?.sources || []; + const schemas = sources.map((source) => { + const sourceDescriptor = source.mode === "wireless" + ? routing.descriptors.find((candidate) => candidate.address === source.address) + : descriptorForWiredMergeInput(descriptor, descriptor.node.inputs?.find((input) => input.name === source.key), routing); + return resolveDescriptorSchema(sourceDescriptor, resolving, routing) || []; + }); + resolving.delete(resolvingKey); + return mergePipeSchemas(schemas); +} + +function resolveSchema(node, resolving = new Set(), descriptorOverride = null) { + const routing = collectExpandedPipeAddresses(app.graph); + const descriptor = descriptorOverride || activeDescriptorFor(node, routing); + const { definition, projection } = schemaProjectionFor(node, descriptor, routing); + const resolvingKey = descriptor?.address || node; + if (resolving.has(resolvingKey)) return projection.resolvedSlots || []; + resolving.add(resolvingKey); + const physicalUpstream = upstreamNode(node); + const crossRoute = physicalUpstream ? null : crossScopeRouteFor(node, routing, descriptor); + const localUpstream = physicalUpstream || (!crossRoute ? wirelessUpstreamNode(node) : null); + const upstream = physicalUpstream || crossRoute?.predecessor?.node || localUpstream; + const upstreamDescriptor = crossRoute?.predecessor || (descriptor && upstream + ? routing.descriptors.find((candidate) => candidate.node === upstream && samePath(candidate.hostPath, descriptor.hostPath)) + : null); + const upstreamSchema = upstreamDescriptor + ? resolveDescriptorSchema(upstreamDescriptor, resolving, routing) + : isSmartPipe(upstream) ? resolveSchema(upstream, resolving, upstreamDescriptor) : null; + let previous = projection.inheritedSlots || []; + if (!upstreamSchema) { + const connectedSlotIds = new Set((node.inputs || []).filter((input) => input.link != null && input.bvSlotId).map((input) => input.bvSlotId)); + const promotionState = { inheritedSlots: projection.inheritedSlots || [], localSlots: definition.localSlots }; + promoteConnectedInheritedSlots(promotionState, connectedSlotIds); + projection.inheritedSlots = promotionState.inheritedSlots; + definition.localSlots = promotionState.localSlots; + previous = projection.inheritedSlots || []; + } + if (upstreamSchema) { + const upstreamIds = new Set(upstreamSchema.map((slot) => slot.id)); + const adopted = definition.localSlots.filter((slot) => upstreamIds.has(slot.id)); + if (adopted.length) { + const projections = new Map(previous.map((slot) => [slot.id, slot])); + for (const slot of adopted) { + const projection = projections.get(slot.id); + projections.set(slot.id, { + ...copySlot(slot), + showInput: projection?.showInput ?? slot.showInput ?? false, + showOutput: projection?.showOutput ?? slot.showOutput ?? false, + }); + } + previous = [...projections.values()]; + definition.localSlots = definition.localSlots.filter((slot) => !upstreamIds.has(slot.id)); + } + const renamed = resolveLocalSlotNameCollisions(definition.localSlots, upstreamSchema.map((slot) => slot.name)); + if (renamed.length) definition.renameNotices = renamed; + } + const inherited = upstreamSchema + ? [ + ...upstreamSchema.map((slot) => { + const projection = previous.find((item) => item.id === slot.id); + const hasInput = slotHasInputConnection(node, slot.id); + const directlyUsed = slotHasConnections(node, slot.id); + const usedDownstream = slotUsedDownstream(node, slot.id); + return { + ...copySlot(slot, Boolean(slot.missing) && !hasInput), + showInput: projection?.showInput ?? false, + showOutput: projection?.showOutput ?? false, + dormant: Boolean(slot.missing) && !hasInput && !directlyUsed && usedDownstream, + }; + }), + ...retainedMissingSlots(previous, new Set(upstreamSchema.map((slot) => slot.id)), (slot) => slotHasConnections(node, slot.id) || slotUsedDownstream(node, slot.id)) + .map((slot) => ({ ...copySlot(slot, true), dormant: !slotHasConnections(node, slot.id) })), + ] + : retainedMissingSlots(previous, new Set(), (slot) => slotHasConnections(node, slot.id) || slotUsedDownstream(node, slot.id)) + .map((slot) => ({ ...copySlot(slot, true), dormant: !slotHasConnections(node, slot.id) })); + + const usedOrdinals = new Set(inherited.map((slot) => slot.ordinal)); + for (const slot of definition.localSlots) { + if (!slot.ordinal || usedOrdinals.has(slot.ordinal)) { + slot.ordinal = nextFreeOrdinal(usedOrdinals, definition.nextOrdinal, MAX_SLOTS); + if (slot.ordinal) definition.nextOrdinal = slot.ordinal + 1; + } + if (slot.ordinal) definition.nextOrdinal = Math.max(definition.nextOrdinal, slot.ordinal + 1); + usedOrdinals.add(slot.ordinal); + } + + projection.inheritedSlots = inherited; + projection.resolvedSlots = [...inherited, ...definition.localSlots.map((slot) => copySlot(slot))] + .filter((slot) => slot.ordinal) + .sort((left, right) => left.ordinal - right.ordinal); + resolving.delete(resolvingKey); + return projection.resolvedSlots; +} + +function removeSlot(node, kind, index) { + if (kind === "input") node.removeInput(index); + else node.removeOutput(index); +} + +function typesAreCompatible(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((type) => rightTypes.includes(type)); +} + +function temporarilyRevealOutput(node, slot) { + return node.__bvSmartPipeRevealTypes?.some((type) => typesAreCompatible(type, slot.type)); +} + +function reconcilePorts(node, schema) { + 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")}`)); + + for (let index = (node.inputs?.length || 0) - 1; index >= 0; index--) { + const slot = node.inputs[index]; + if (slot.bvAddSlot) continue; + if (slot.name !== "pipe" && !desiredInputs.has(slot.name) && slot.link == null) removeSlot(node, "input", index); + } + for (let index = (node.outputs?.length || 0) - 1; index >= 0; index--) { + const slot = node.outputs[index]; + if (slot.name !== "pipe" && !desiredOutputs.has(slot.name) && !(slot.links?.length)) removeSlot(node, "output", index); + } + + + for (const slot of schema) { + const inputName = `v_${String(slot.ordinal).padStart(3, "0")}`; + const outputName = `out_${String(slot.ordinal).padStart(3, "0")}`; + const label = `${slot.missing ? "⚠ Missing: " : ""}${slot.name}`; + let inputIndex = reusableSmartPipePortIndex(node.inputs, { slotId: slot.id, portName: inputName, label: slot.name, type: slot.type }); + let input = inputIndex >= 0 ? node.inputs[inputIndex] : null; + if (!slot.dormant && (slot.showInput || slot.missing) && !input) input = node.addInput(inputName, slot.type || "*"); + if (input) { + inputIndex = node.inputs.indexOf(input); + node.inputs[inputIndex] = updateSmartPipePort(input, label, slot.type || "*", slot.id); + node.inputs[inputIndex].name = inputName; + } + let outputIndex = reusableSmartPipePortIndex(node.outputs, { slotId: slot.id, portName: outputName, label: slot.name, type: slot.type }); + let output = outputIndex >= 0 ? node.outputs[outputIndex] : null; + if (!slot.dormant && (slot.showOutput || slot.missing || temporarilyRevealOutput(node, slot)) && !output) output = node.addOutput(outputName, slot.type || "*"); + if (output) { + outputIndex = node.outputs.indexOf(output); + node.outputs[outputIndex] = updateSmartPipePort(output, label, slot.type || "*", slot.id); + node.outputs[outputIndex].name = outputName; + } + } + + 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 addSlots = (node.inputs || []).filter((input) => input.bvAddSlot); + const keepAddSlot = schema.length < MAX_SLOTS; + for (let index = addSlots.length - 1; index >= (keepAddSlot ? 1 : 0); index--) removeSlot(node, "input", node.inputs.indexOf(addSlots[index])); + if (keepAddSlot && !addSlots.length) { + const addSlot = node.addInput(ADD_SLOT_NAME, "*"); + addSlot.label = "+ Add Slot"; + addSlot.localized_name = "+ Add Slot"; + addSlot.bvAddSlot = true; + } + moveMarkedPortToEnd(node.inputs, (input) => input.bvAddSlot); + node.inputs = [...(node.inputs || [])]; + node.outputs = [...(node.outputs || [])]; +} + +function sourceOutput(node, linkInfo) { + const source = node.graph?.getNodeById?.(linkInfo?.origin_id); + return source?.outputs?.[linkInfo?.origin_slot] || null; +} + +function convertAddSlot(node, index, linkInfo) { + const input = node.inputs?.[index]; + if (!input?.bvAddSlot) return false; + const state = stateFor(node); + const schema = resolveSchema(node); + const usedOrdinals = new Set(schema.map((slot) => slot.ordinal)); + const ordinal = nextFreeOrdinal(usedOrdinals, state.nextOrdinal, MAX_SLOTS); + if (!ordinal) return false; + + const output = sourceOutput(node, linkInfo); + const baseName = smartPipeSlotName(output?.localized_name || output?.label || output?.name); + const name = uniqueSmartPipeSlotName(baseName, new Set(schema.map((slot) => slot.name))); + const inferredType = linkInfo?.type || linkInfo?.origin_type || output?.type; + const type = inferredType && inferredType !== "*" ? inferredType : "*"; + const slot = { id: newId(), name, ordinal, type, showInput: true, showOutput: false }; + state.localSlots.push(slot); + state.nextOrdinal = ordinal + 1; + + const converted = updateSmartPipePort(input, name, type, slot.id); + converted.name = `v_${String(ordinal).padStart(3, "0")}`; + delete converted.bvAddSlot; + node.inputs[index] = converted; + return true; +} + +function updateSchemaWidget(node, schema) { + const widget = node.widgets?.find((item) => item.name === "bv_smart_pipe_schema_json"); + if (!widget) return; + const serialized = schema.map((slot) => { + const input = node.inputs?.find((item) => item.bvSlotId === slot.id); + const output = node.outputs?.find((item) => item.bvSlotId === slot.id); + return { ...slot, connected: input?.link != null || Boolean(output?.links?.length) }; + }); + widget.value = JSON.stringify(serialized); + widget.computeSize = () => [0, -4]; + widget.type = "converted-widget"; + widget.draw = () => {}; + widget.hidden = true; + widget.options ??= {}; + widget.options.hidden = true; + if (widget.element) widget.element.style.display = "none"; +} + +function updateRouteWidget(node) { + const route = routingFor(node); + const widget = node.widgets?.find((item) => item.name === ROUTE_INPUT); + if (!widget) return; + widget.value = JSON.stringify(route); + widget.computeSize = () => [0, -4]; + widget.type = "converted-widget"; + widget.draw = () => {}; + widget.hidden = true; + widget.options ??= {}; + widget.options.hidden = true; + if (widget.element) widget.element.style.display = "none"; +} + +function wirelessChoices(node) { + const nodes = pipeNodes(node.graph); + const routing = collectExpandedPipeAddresses(app.graph); + const ownDescriptor = activeDescriptorFor(node, routing); + const scopeDescriptors = ownDescriptor + ? routing.descriptors.filter((descriptor) => samePath(descriptor.hostPath, ownDescriptor.hostPath)) + : []; + const descriptorByAddress = new Map(scopeDescriptors.map((descriptor) => [descriptor.address, descriptor])); + const displayRoutes = scopeDescriptors.length + ? scopeDescriptors.map((descriptor) => { + if (descriptor.kind === "merge") { + const sources = routing.registry[descriptor.address]?.sources + || descriptor.node.properties?.bvSmartPipeMerge?.sources + || []; + return { + nodeId: descriptor.route.nodeId, + name: descriptor.route.name, + mode: "merge", + sourceIds: sources.map((source) => descriptorByAddress.get(source.address)?.route.nodeId).filter(Boolean), + }; + } + const predecessor = effectiveUpstreamNode(descriptor.node); + const predecessorId = predecessor ? routableNodeId(predecessor) : null; + return { + ...descriptor.route, + mode: predecessorId ? "follow" : "root", + predecessorId, + }; + }) + : nodes.map(routingFor); + const numbers = routingDisplayNumbers(predecessorChoiceRoutes(displayRoutes, ownDescriptor?.route.nodeId)); + const choices = []; + for (const candidate of nodes) { + if (candidate === node) continue; + const visited = new Set(); + let cursor = candidate; + let createsCycle = false; + while (cursor && !visited.has(cursor)) { + if (cursor === node) { + createsCycle = true; + break; + } + visited.add(cursor); + cursor = effectiveUpstreamNode(cursor); + } + if (createsCycle) continue; + const route = routingFor(candidate); + const number = numbers.get(route.nodeId); + choices.push({ label: routingChoiceLabel(number, route.name), id: route.nodeId, address: null, number, scope: "local" }); + } + if (ownDescriptor) { + const sameScopeDescriptors = routing.descriptors + .filter((descriptor) => descriptor.address !== ownDescriptor.address && samePath(descriptor.hostPath, ownDescriptor.hostPath)); + for (const descriptor of sameScopeDescriptors.filter((candidate) => candidate.kind === "merge")) { + const number = numbers.get(descriptor.route.nodeId); + choices.push({ label: routingChoiceLabel(number, descriptor.route.name), id: null, address: descriptor.address, number, scope: "local" }); + } + choices.sort((left, right) => compareRoutingNumbers(left.number, right.number) || left.label.localeCompare(right.label)); + const crossChoices = crossScopeDescriptors(routing.descriptors, ownDescriptor) + .map((descriptor) => { + const scope = descriptor.hostNames.length ? descriptor.hostNames.join(" › ") : "Root"; + return { label: `↗ ${scope} › ${descriptor.route.name}`, id: null, address: descriptor.address, number: "", scope }; + }) + .sort((left, right) => left.scope.localeCompare(right.scope) || left.label.localeCompare(right.label)); + choices.push(...crossChoices); + } else { + choices.sort((left, right) => compareRoutingNumbers(left.number, right.number) || left.label.localeCompare(right.label)); + } + return [{ label: "Start new pipe", id: null, address: null, number: "0", scope: "local" }, ...choices]; +} + +function updatePredecessorWidget(node) { + const widget = node.__bvPredecessorWidget; + if (!widget) return; + const wired = upstreamNode(node); + const route = routingFor(node); + const choices = wirelessChoices(node); + if (wired) { + widget.options.values = [`Wired: ${routingFor(wired).name}`]; + widget.value = widget.options.values[0]; + widget.disabled = false; + widget.__bvWired = true; + return; + } + widget.__bvWired = false; + widget.__bvChoices = choices; + widget.options.values = choices.map((choice) => choice.label); + const crossRoute = crossScopeRouteFor(node); + widget.value = crossRoute + ? choices.find((choice) => choice.address === crossRoute.predecessor.address)?.label || `⚠ Missing: ${crossRoute.edge.predecessorAddress}` + : choices.find((choice) => choice.id === route.predecessorId)?.label || "Start new pipe"; +} + +export function propagate(node, visited = new Set()) { + if (!node || visited.has(node)) return; + visited.add(node); + registerUpstream(node, effectiveUpstreamNode(node)); + const schema = resolveSchema(node); + reconcilePorts(node, schema); + updateSchemaWidget(node, schema); + updateRouteWidget(node); + updatePredecessorWidget(node); + const visibleSlots = Math.max((node.inputs?.length || 1) - 1, (node.outputs?.length || 1) - 1); + node.setSize?.([Math.max(260, node.size?.[0] || 0), 90 + visibleSlots * 22]); + node.setDirtyCanvas?.(true, true); + for (const target of node.__bvSmartPipeTargets || []) propagate(target, visited); +} + +function validateSlots(node) { + const state = stateFor(node); + const names = new Set(); + for (const slot of resolveSchema(node)) { + const local = state.localSlots.find((item) => item.id === slot.id); + if (local) { + local.name = local.name.trim(); + local.showInput = true; + } + const name = local?.name ?? slot.name; + if (!name) throw new Error("Jeder lokale Slot benötigt einen Namen."); + if (names.has(name)) throw new Error(`Duplicate slot name: ${name}`); + names.add(name); + } +} + +function openEditor(node) { + const { definition: state, projection } = schemaProjectionFor(node); + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:100000;background:rgb(0 0 0 / 65%);backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center"; + const panel = document.createElement("div"); + panel.className = "bv-smart-pipe-editor"; + panel.style.cssText = "width:min(900px,92vw);max-height:86vh;overflow:auto;background:var(--comfy-menu-bg,#202020);color:var(--input-text,#eee);border:1px solid var(--border-color,#555);border-radius:12px;padding:20px;box-shadow:0 18px 60px #000a;font:14px sans-serif"; + overlay.append(panel); + const title = document.createElement("h2"); + title.textContent = "BV Smart Pipe konfigurieren"; + title.style.marginTop = "0"; + panel.append(title); + const error = document.createElement("div"); + error.style.cssText = "color:#ff8c8c;min-height:20px"; + panel.append(error); + if (state.renameNotices?.length) { + const notice = document.createElement("div"); + notice.style.cssText = "color:var(--descrip-text,#bbb);margin:0 0 10px"; + notice.textContent = `Namenskonflikt automatisch aufgelöst: ${state.renameNotices.map((item) => `${item.from} → ${item.to}`).join(", ")}`; + panel.append(notice); + } + const table = document.createElement("table"); + table.style.cssText = "width:100%;border-collapse:collapse"; + panel.append(table); + + const render = () => { + table.replaceChildren(); + const head = table.insertRow(); + for (const label of ["Status", "Name", "Typ", "Input", "Output", "Aktion"]) { + const cell = document.createElement("th"); + cell.textContent = label; + cell.style.textAlign = "left"; + head.append(cell); + } + for (const slot of resolveSchema(node)) { + const local = state.localSlots.find((item) => item.id === slot.id); + const row = table.insertRow(); + row.insertCell().textContent = slot.missing ? "⚠ Fehlt" : local ? "Lokal" : "Geerbt"; + const nameCell = row.insertCell(); + const name = document.createElement("input"); + name.value = slot.name; + name.disabled = !local; + name.oninput = () => { if (local) local.name = name.value; }; + nameCell.append(name); + const typeCell = row.insertCell(); + const type = document.createElement("input"); + type.value = slot.type || "*"; + type.disabled = !local; + type.oninput = () => { if (local) local.type = type.value.trim() || "*"; }; + typeCell.append(type); + for (const field of ["showInput", "showOutput"]) { + const cell = row.insertCell(); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = field === "showInput" && local ? true : slot[field] ?? false; + checkbox.disabled = field === "showInput" && Boolean(local); + if (checkbox.disabled) checkbox.title = "Lokale Slots benötigen einen Input, weil hier ihr Wert erzeugt wird."; + checkbox.onchange = () => { + const target = local || projection.inheritedSlots.find((item) => item.id === slot.id); + if (target) target[field] = checkbox.checked; + }; + cell.append(checkbox); + } + const action = row.insertCell(); + if (local || slot.missing) { + const remove = document.createElement("button"); + remove.textContent = "Entfernen"; + remove.onclick = () => { + state.localSlots = state.localSlots.filter((item) => item.id !== slot.id); + projection.inheritedSlots = projection.inheritedSlots.filter((item) => item.id !== slot.id); + render(); + }; + action.append(remove); + } + } + }; + render(); + + const paste = document.createElement("textarea"); + paste.placeholder = "Mehrere neue Slots – ein Name pro Zeile"; + paste.style.cssText = "width:100%;height:80px;margin-top:14px;box-sizing:border-box;resize:vertical"; + panel.append(paste); + const actions = document.createElement("div"); + actions.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:12px"; + panel.append(actions); + const add = document.createElement("button"); + add.textContent = "Slots hinzufügen"; + add.onclick = () => { + const names = paste.value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); + const additions = names.length ? names : ["new_slot"]; + if (state.localSlots.length + (projection.inheritedSlots?.length || 0) + additions.length > MAX_SLOTS) { + error.textContent = `Maximal ${MAX_SLOTS} Slots sind erlaubt.`; + return; + } + const usedOrdinals = new Set(resolveSchema(node).map((slot) => slot.ordinal)); + const planned = []; + let nextOrdinal = state.nextOrdinal; + for (const name of additions) { + const ordinal = nextFreeOrdinal(usedOrdinals, nextOrdinal, MAX_SLOTS); + if (!ordinal) { + error.textContent = `Keine freie Slotposition bis zum Limit ${MAX_SLOTS} verfügbar.`; + return; + } + planned.push({ id: newId(), name, ordinal, type: "*", showInput: true, showOutput: false }); + usedOrdinals.add(ordinal); + nextOrdinal = ordinal + 1; + } + state.localSlots.push(...planned); + state.nextOrdinal = nextOrdinal; + paste.value = ""; + render(); + }; + actions.append(add); + const cancel = document.createElement("button"); + cancel.textContent = "Abbrechen"; + cancel.onclick = () => overlay.remove(); + actions.append(cancel); + const save = document.createElement("button"); + save.textContent = "Speichern"; + save.onclick = () => { + try { + validateSlots(node); + propagate(node); + overlay.remove(); + } catch (caught) { + error.textContent = caught.message; + } + }; + actions.append(save); + overlay.onclick = (event) => { if (event.target === overlay) overlay.remove(); }; + document.body.append(overlay); +} + +function setupNode(node) { + if (node.__bvSmartPipeReady) return; + node.__bvSmartPipeReady = true; + stateFor(node); + routingFor(node); + const predecessor = node.addWidget?.("combo", "Pipe predecessor", "Start new pipe", (label) => { + if (upstreamNode(node) || predecessor.__bvWired) return; + const route = routingFor(node); + const selected = predecessor.__bvChoices?.find((choice) => choice.label === label); + const routing = collectExpandedPipeAddresses(app.graph); + const destination = activeDescriptorFor(node, routing); + if (destination) delete routing.registry[destination.address]; + if (selected?.address && destination) { + routing.registry[destination.address] = { version: 1, predecessorAddress: selected.address }; + route.mode = "root"; + route.predecessorId = null; + } else { + route.mode = selected?.id ? "follow" : "root"; + route.predecessorId = selected?.id || null; + } + requestAnimationFrame(() => propagateGraph(node.graph)); + }, { values: ["Start new pipe"], serialize: false }); + if (predecessor) { + predecessor.label = "Pipe predecessor"; + predecessor.serialize = false; + node.__bvPredecessorWidget = predecessor; + } + const button = node.addWidget?.("button", "Configure", null, () => openEditor(node), { serialize: false }); + if (button) button.label = "Configure Smart Pipe"; + const originalConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function (type, index, connected, linkInfo) { + originalConnectionsChange?.apply(this, arguments); + if (type === 1 && connected) { + if (convertAddSlot(this, index, linkInfo)) { + requestAnimationFrame(() => propagate(this)); + return; + } + if (index <= 0) return; + const input = this.inputs?.[index]; + const slot = resolveSchema(this).find((item) => item.id === input?.bvSlotId); + const inferred = linkInfo?.type || linkInfo?.origin_type; + if (slot?.missing) { + const { definition, projection } = schemaProjectionFor(this); + const promotionState = { inheritedSlots: projection.inheritedSlots || [], localSlots: definition.localSlots }; + promoteInheritedSlot(promotionState, slot.id, inferred); + projection.inheritedSlots = promotionState.inheritedSlots; + definition.localSlots = promotionState.localSlots; + } + if (slot && slot.type === "*" && inferred && inferred !== "*") { + const local = stateFor(this).localSlots.find((item) => item.id === slot.id); + if (local) local.type = inferred; + } + } + if (type === 1 && index === 0 && !connected) { + const route = routingFor(this); + route.mode = "root"; + route.predecessorId = null; + } + if (type === 2 && connected) { + const output = this.outputs?.[index]; + const slot = resolveSchema(this).find((item) => item.id === output?.bvSlotId); + const projection = schemaProjectionFor(this).projection; + const target = stateFor(this).localSlots.find((item) => item.id === slot?.id) + || projection.inheritedSlots.find((item) => item.id === slot?.id); + if (target) target.showOutput = true; + } + requestAnimationFrame(() => propagate(this)); + }; + requestAnimationFrame(() => propagate(node)); +} + +function installPromptMaterializer() { + if (app.__bvSmartPipePromptMaterializer || typeof app.graphToPrompt !== "function") return; + app.__bvSmartPipePromptMaterializer = true; + const original = app.graphToPrompt; + app.graphToPrompt = async function () { + const seenGraphs = new Set(); + const refreshGraph = (graph) => { + if (!graph || seenGraphs.has(graph)) return; + seenGraphs.add(graph); + for (const node of graph._nodes || graph.nodes || []) { + if (isSmartPipe(node)) propagate(node); + const subgraph = subgraphFor(node); + if (subgraph) refreshGraph(subgraph); + } + }; + refreshGraph(app.graph); + const routing = collectExpandedPipeAddresses(app.graph); + const modeState = promptModeState(routing.descriptors); + const { prunedExecutionIds } = modeState; + const localModeState = { + routesByScopedNodeId: new Map(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe") + .map((descriptor) => [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, descriptor.route])), + bypassedScopedNodeIds: new Set(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 4) + .map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)), + mutedScopedNodeIds: new Set(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 2) + .map((descriptor) => `${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`)), + prunedExecutionIds, + bypassPredecessorsByScopedNodeId: new Map(routing.descriptors.filter((descriptor) => descriptor.kind === "pipe" && descriptor.node?.mode === 4) + .map((descriptor) => { + const edge = routing.registry[descriptor.address]; + const predecessor = edge?.predecessorAddress + ? routing.descriptors.find((candidate) => candidate.address === edge.predecessorAddress) + : routing.descriptors.find((candidate) => candidate.hostPath.length === descriptor.hostPath.length + && candidate.hostPath.every((part, index) => part === descriptor.hostPath[index]) + && candidate.route.nodeId === descriptor.route.predecessorId); + return predecessor ? [`${executionScope(descriptor.executionId)}\u0000${descriptor.route.nodeId}`, { + executionId: predecessor.executionId, + route: predecessor.route, + scope: executionScope(predecessor.executionId), + }] : null; + }).filter(Boolean)), + }; + const result = await original.apply(this, arguments); + applyInstanceSchemaProjections(result?.output, routing); + applyMergeConfigurations(result?.output, routing); + remapSmartPipeOutputLinks(result?.output, routing); + materializeAddressedPipeLinks(result?.output, routing.addressByExecutionId, routing.registry, modeState); + materializeSmartPipeMergeSources(result?.output, routing.addressByExecutionId, routing.registry, modeState); + materializeWirelessPipeLinks(result?.output, localModeState); + prunePromptBranches(result?.output, prunedExecutionIds); + validateMaterializedPipeGraph(result?.output); + return result; + }; +} + +let revealMonitorStarted = false; +const routingSignatures = new WeakMap(); +let lastHostRoutingCheck = 0; +let hostRoutingSignature = ""; +const registryCleanupStates = new WeakMap(); + +function monitorHostRoutingNames(timestamp) { + if (!app.graph || timestamp - lastHostRoutingCheck < 250) return; + lastHostRoutingCheck = timestamp; + const routing = collectExpandedPipeAddresses(app.graph); + let cleanupState = registryCleanupStates.get(app.graph); + if (!cleanupState) { + cleanupState = { missingSince: new Map(), backups: new Map() }; + registryCleanupStates.set(app.graph, cleanupState); + } + reconcileRouteRegistryDestinations( + routing.registry, + new Set(routing.descriptors.map((descriptor) => descriptor.address)), + cleanupState, + timestamp, + ); + const nextSignature = routing.descriptors + .map((descriptor) => `${descriptor.address}\u0000${descriptor.hostNames.join("\u0000")}`) + .sort() + .join("\u0001"); + if (hostRoutingSignature && hostRoutingSignature !== nextSignature) { + const graphs = new Set(routing.descriptors.map((descriptor) => descriptor.node.graph)); + graphs.forEach((graph) => propagateGraph(graph)); + } + hostRoutingSignature = nextSignature; +} + +function monitorRoutingNames(graph, visited = new Set()) { + if (!graph || visited.has(graph)) return; + visited.add(graph); + const nodes = pipeNodes(graph); + const signature = nodes.map((node) => { + const route = routingFor(node); + return `${route.nodeId}\u0000${route.name}\u0000${node.title}\u0000${route.mode}\u0000${route.predecessorId || ""}`; + }).join("\u0001"); + if (routingSignatures.has(graph) && routingSignatures.get(graph) !== signature) { + propagateGraph(graph); + } + routingSignatures.set(graph, nodes.map((node) => { + const route = routingFor(node); + return `${route.nodeId}\u0000${route.name}\u0000${node.title}\u0000${route.mode}\u0000${route.predecessorId || ""}`; + }).join("\u0001")); + for (const node of graphNodes(graph)) monitorRoutingNames(subgraphFor(node), visited); +} + +function startTypedOutputRevealMonitor() { + if (revealMonitorStarted) return; + revealMonitorStarted = true; + let previousKey = ""; + let previousGraph = null; + const monitor = (timestamp = performance.now()) => { + monitorHostRoutingNames(timestamp); + monitorRoutingNames(app.graph); + const connector = app.canvas?.linkConnector; + const renderLinks = connector?.renderLinks || []; + const types = connector?.state?.connectingTo === "output" + ? [...new Set(renderLinks.map((link) => link.fromSlot?.type).filter(Boolean).map(String))] + : []; + const key = types.slice().sort().join("|"); + const currentGraph = app.canvas?.getCurrentGraph?.() || app.canvas?.graph || app.graph; + if (key !== previousKey || currentGraph !== previousGraph) { + previousKey = key; + previousGraph = currentGraph; + for (const node of graphNodes(currentGraph)) { + if (!isSmartPipe(node)) continue; + node.__bvSmartPipeRevealTypes = types; + propagate(node); + } + } + requestAnimationFrame(monitor); + }; + requestAnimationFrame(monitor); +} + +app.registerExtension({ + name: "bv_nodepack.smart_pipe", + init() { + installSubgraphContextTracking(); + startTypedOutputRevealMonitor(); + installPromptMaterializer(); + if (document.getElementById("bv-internal-widget-style")) return; + const style = document.createElement("style"); + style.id = "bv-internal-widget-style"; + style.textContent = ` + .dom-widget:has(textarea[placeholder="bv_smart_pipe_schema_json"]), .dom-widget:has(textarea[placeholder="bv_smart_pipe_route_json"]), .dom-widget:has(textarea[placeholder="bv_control_config_json"]) { display: none !important; } + .bv-smart-pipe-editor h2 { font-size: 1.2rem; font-weight: 650; margin-bottom: 12px; } + .bv-smart-pipe-editor table { border-spacing: 0; overflow: hidden; border: 1px solid var(--border-color, #555); border-radius: 8px; } + .bv-smart-pipe-editor th { color: var(--descrip-text, #bbb); font-size: 12px; font-weight: 600; padding: 8px 10px; background: var(--comfy-menu-secondary-bg, #292929); } + .bv-smart-pipe-editor td { padding: 7px 10px; border-top: 1px solid var(--border-color, #444); } + .bv-smart-pipe-editor input[type="text"], .bv-smart-pipe-editor input:not([type]), .bv-smart-pipe-editor textarea { color: var(--input-text, #eee); background: var(--comfy-input-bg, #181818); border: 1px solid var(--border-color, #555); border-radius: 6px; padding: 7px 9px; } + .bv-smart-pipe-editor button { color: var(--input-text, #eee); background: var(--comfy-menu-secondary-bg, #333); border: 1px solid var(--border-color, #555); border-radius: 6px; padding: 7px 11px; cursor: pointer; } + .bv-smart-pipe-editor button:hover { background: var(--content-hover-bg, #444); } + .bv-smart-pipe-editor input:disabled { opacity: .55; cursor: not-allowed; } + `; + document.head.append(style); + }, + async nodeCreated(node) { + if (isSmartPipe(node)) { + setupNode(node); + requestAnimationFrame(() => pipeNodes(node.graph).forEach((candidate) => propagate(candidate))); + } + }, + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_CLASS) return; + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = originalConfigure?.apply(this, arguments); + setupNode(this); + requestAnimationFrame(() => propagate(this)); + return result; + }; + const originalMenu = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + originalMenu?.apply(this, arguments); + options.unshift({ content: "Configure Smart Pipe", callback: () => openEditor(this) }); + }; + }, +}); diff --git a/js/bv_smart_pipe_merge.js b/js/bv_smart_pipe_merge.js new file mode 100644 index 0000000..8e732fd --- /dev/null +++ b/js/bv_smart_pipe_merge.js @@ -0,0 +1,333 @@ +import { app } from "../../scripts/app.js"; +import { activeDescriptorFor, collectExpandedPipeAddresses, propagate } from "./bv_smart_pipe.js"; +import { + compactMergeNodeHeight, + duplicateDynamicInputIndexes, + hasMergeSource, + normalizeMergeSources, + SMART_PIPE_MERGE_DEFAULT_TITLE, +} from "./bv_smart_pipe_merge_model.js"; + +const NODE_CLASS = "BV Smart Pipe Merge"; +const CONFIG_INPUT = "bv_smart_pipe_merge_json"; +const ADD_SOURCE = "bv_add_pipe_source"; +const MAX_SOURCES = 16; + +function isMerge(node) { + return node?.comfyClass === NODE_CLASS || node?.type === NODE_CLASS; +} + +function newId() { + return globalThis.crypto?.randomUUID?.() ?? `bv-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function stateFor(node) { + node.properties ??= {}; + const initialName = !node.title || ["Pipe", "Pipe Merge"].includes(node.title) ? SMART_PIPE_MERGE_DEFAULT_TITLE : node.title; + node.properties.bvSmartPipeMerge ??= { version: 1, nodeId: newId(), name: initialName, sources: [] }; + const state = node.properties.bvSmartPipeMerge; + state.name ??= initialName; + state.sources ??= []; + state.nextOrdinal ??= Math.max(0, ...state.sources.map((source) => Number.parseInt(source.key?.split("_")[1], 10) || 0)) + 1; + return state; +} + +function syncTitle(node) { + const state = stateFor(node); + if (node.__bvSmartPipeMergeAppliedTitle && node.title !== node.__bvSmartPipeMergeAppliedTitle && !["Pipe", "Pipe Merge"].includes(node.title)) { + state.name = node.title; + } + node.title = state.name || SMART_PIPE_MERGE_DEFAULT_TITLE; + node.__bvSmartPipeMergeAppliedTitle = node.title; +} + +function activeSources(node, routing = collectExpandedPipeAddresses(app.graph)) { + const state = stateFor(node); + const widget = configWidget(node); + if (!state.sources.length && typeof widget?.value === "string") { + try { + const persisted = JSON.parse(widget.value); + if (Array.isArray(persisted)) state.sources = persisted; + } catch { + // Invalid hidden configuration is ignored and replaced on the next sync. + } + } + const connectedWiredKeys = new Set( + (node.inputs || []) + .filter((input) => /^pipe_\d+$/.test(input.name || "") && input.link != null) + .map((input) => input.name), + ); + state.sources = normalizeMergeSources(state.sources, connectedWiredKeys); + const descriptor = activeDescriptorFor(node, routing); + if (!descriptor) return { sources: state.sources, descriptor: null, routing }; + routing.registry[descriptor.address] ??= { version: 1, kind: "merge", sources: structuredClone(state.sources) }; + const entry = routing.registry[descriptor.address]; + entry.kind = "merge"; + entry.sources ??= structuredClone(state.sources); + entry.sources = normalizeMergeSources(entry.sources, connectedWiredKeys); + state.sources = structuredClone(entry.sources); + return { sources: entry.sources, descriptor, routing }; +} + +function ensureUniqueId(node) { + const state = stateFor(node); + const siblings = node.graph?._nodes || node.graph?.nodes || []; + if (siblings.some((candidate) => candidate !== node && isMerge(candidate) && stateFor(candidate).nodeId === state.nodeId)) { + state.nodeId = newId(); + } +} + +function configWidget(node) { + return node.widgets?.find((widget) => widget.name === CONFIG_INPUT); +} + +function syncConfig(node) { + const { sources } = activeSources(node); + const widget = configWidget(node); + if (widget) widget.value = JSON.stringify(sources); +} + +function hideConfigWidget(node) { + const widget = configWidget(node); + if (!widget) return; + widget.computeSize = () => [0, -4]; + widget.hidden = true; +} + +function sourceInput(node, key) { + return node.inputs?.find((input) => input.name === key); +} + +function ensureAddSource(node) { + let input = node.inputs?.find((slot) => slot.bvAddPipeSource || slot.name === ADD_SOURCE); + if (!input) input = node.addInput(ADD_SOURCE, "BV_SMART_PIPE"); + input.name = ADD_SOURCE; + input.label = "+ Add Pipe Source"; + input.type = "BV_SMART_PIPE"; + input.bvAddPipeSource = true; +} + +function reconcileInputs(node) { + syncTitle(node); + for (const index of duplicateDynamicInputIndexes(node.inputs)) { + const input = node.inputs[index]; + if (input?.link != null) node.graph?.removeLink?.(input.link); + node.removeInput(index); + } + const { sources, descriptor, routing } = activeSources(node); + const seenAddresses = new Set(); + for (let index = sources.length - 1; index >= 0; index--) { + const source = sources[index]; + if (source.mode === "wired" && !source.address) { + const input = sourceInput(node, source.key); + const link = input?.link == null ? null : node.graph?.links?.get?.(input.link) ?? node.graph?.links?.[input.link]; + const originNode = link ? node.graph?.getNodeById?.(link.origin_id) : null; + source.address = routing.descriptors.find((candidate) => candidate.node === originNode && (!descriptor || candidate.hostPath.join("/") === descriptor.hostPath.join("/")))?.address || null; + } + if (!source.address || !seenAddresses.has(source.address)) { + if (source.address) seenAddresses.add(source.address); + continue; + } + const input = sourceInput(node, source.key); + if (input?.link != null) node.graph?.removeLink?.(input.link); + sources.splice(index, 1); + } + const wired = new Set(sources.filter((source) => source.mode === "wired").map((source) => source.key)); + for (let index = (node.inputs?.length || 0) - 1; index >= 0; index--) { + const input = node.inputs[index]; + if (input.name === CONFIG_INPUT || input.bvAddPipeSource || input.name === ADD_SOURCE) continue; + if (/^pipe_\d+$/.test(input.name) && !wired.has(input.name) && input.link == null) node.removeInput(index); + } + for (const source of sources) { + if (source.mode !== "wired") continue; + let input = sourceInput(node, source.key); + if (!input) input = node.addInput(source.key, "BV_SMART_PIPE"); + input.label = source.label || "Wired Pipe"; + input.type = "BV_SMART_PIPE"; + } + ensureAddSource(node); + hideConfigWidget(node); + syncConfig(node); + const wiredCount = sources.filter((source) => source.mode === "wired").length; + node.setSize?.([Math.max(280, node.size?.[0] || 0), compactMergeNodeHeight(wiredCount)]); + node.setDirtyCanvas?.(true, true); + for (const target of node.__bvSmartPipeTargets || []) propagate(target); +} + +function convertAddSource(node, index, linkInfo) { + const input = node.inputs?.[index]; + if (!input?.bvAddPipeSource && input?.name !== ADD_SOURCE) return false; + const { sources } = activeSources(node); + if (sources.length >= MAX_SOURCES) return false; + const routing = collectExpandedPipeAddresses(app.graph); + const destination = activeDescriptorFor(node, routing); + const originNode = node.graph?.getNodeById?.(linkInfo?.origin_id); + const origin = routing.descriptors.find((descriptor) => descriptor.node === originNode && (!destination || descriptor.hostPath.join("/") === destination.hostPath.join("/"))); + const candidate = { mode: "wired", address: origin?.address || null }; + if (hasMergeSource(sources, candidate)) { + requestAnimationFrame(() => { + const duplicateInput = node.inputs?.[index]; + if (duplicateInput?.link != null) node.graph?.removeLink?.(duplicateInput.link); + reconcileInputs(node); + }); + return true; + } + const state = stateFor(node); + let ordinal = state.nextOrdinal; + const used = new Set(sources.map((source) => source.key)); + while (used.has(`pipe_${String(ordinal).padStart(3, "0")}`)) ordinal++; + const key = `pipe_${String(ordinal).padStart(3, "0")}`; + state.nextOrdinal = ordinal + 1; + input.name = key; + input.label = `Wired ${sources.length + 1}`; + input.bvAddPipeSource = false; + sources.push({ key, mode: "wired", address: candidate.address, label: origin?.route?.name || input.label }); + requestAnimationFrame(() => reconcileInputs(node)); + return true; +} + +function removeSource(node, source) { + const { sources } = activeSources(node); + const input = sourceInput(node, source.key); + if (input?.link != null) node.graph?.removeLink?.(input.link); + const index = node.inputs?.indexOf(input) ?? -1; + if (index >= 0) node.removeInput(index); + sources.splice(sources.indexOf(source), 1); +} + +function openEditor(node) { + const routing = collectExpandedPipeAddresses(app.graph); + const current = activeDescriptorFor(node, routing); + const { sources } = activeSources(node, routing); + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:100000;background:#000a;backdrop-filter:blur(2px);display:flex;align-items:center;justify-content:center"; + const panel = document.createElement("div"); + panel.className = "bv-smart-pipe-editor bv-smart-pipe-merge-editor"; + panel.style.cssText = "width:min(760px,92vw);max-height:86vh;overflow:auto;background:var(--comfy-menu-bg,#202020);color:var(--input-text,#eee);border:1px solid var(--border-color,#555);border-radius:12px;padding:20px;box-shadow:0 18px 60px #000a;font:14px sans-serif"; + overlay.append(panel); + const heading = document.createElement("h2"); + heading.textContent = "BV Smart Pipe Merge konfigurieren"; + heading.style.marginTop = "0"; + panel.append(heading); + const hint = document.createElement("p"); + hint.textContent = "Die Reihenfolge gilt von oben nach unten. Bei gleicher Slot-ID gewinnt die spätere Quelle."; + hint.style.color = "var(--descrip-text,#bbb)"; + panel.append(hint); + const list = document.createElement("div"); + list.style.cssText = "display:grid;gap:7px"; + panel.append(list); + + let refreshDropdown = () => {}; + const render = () => { + list.replaceChildren(); + sources.forEach((source, index) => { + const row = document.createElement("div"); + row.style.cssText = "display:grid;grid-template-columns:34px 1fr auto auto auto;gap:6px;align-items:center;padding:7px;border:1px solid var(--border-color,#555);border-radius:8px;background:var(--comfy-menu-secondary-bg,#292929)"; + const number = document.createElement("span"); + number.textContent = `${index + 1}.`; + const label = document.createElement("span"); + label.textContent = `${source.mode === "wired" ? "🔗" : "⌁"} ${source.label || source.key}`; + const up = document.createElement("button"); up.textContent = "↑"; up.disabled = index === 0; + up.onclick = () => { [sources[index - 1], sources[index]] = [sources[index], sources[index - 1]]; render(); }; + const down = document.createElement("button"); down.textContent = "↓"; down.disabled = index === sources.length - 1; + down.onclick = () => { [sources[index + 1], sources[index]] = [sources[index], sources[index + 1]]; render(); }; + const remove = document.createElement("button"); remove.textContent = "Entfernen"; + remove.onclick = () => { removeSource(node, source); render(); }; + row.append(number, label, up, down, remove); + list.append(row); + }); + refreshDropdown(); + }; + render(); + + const addRow = document.createElement("div"); + addRow.style.cssText = "display:flex;gap:8px;margin-top:14px"; + const select = document.createElement("select"); + select.style.cssText = "flex:1;color:var(--input-text,#eee);background:var(--comfy-input-bg,#181818);border:1px solid var(--border-color,#555);border-radius:6px;padding:7px"; + let candidates = []; + const addWireless = document.createElement("button"); + addWireless.textContent = "Wireless hinzufügen"; + refreshDropdown = () => { + const previousAddress = select.value; + candidates = routing.descriptors.filter((descriptor) => descriptor.address !== current?.address && !hasMergeSource(sources, { address: descriptor.address })); + select.replaceChildren(); + for (const descriptor of candidates) { + const option = document.createElement("option"); + option.value = descriptor.address; + option.textContent = `${descriptor.hostNames.length ? `${descriptor.hostNames.join(" › ")} › ` : ""}${descriptor.route.name || descriptor.node.title || "Pipe"}`; + select.append(option); + } + if (candidates.some((candidate) => candidate.address === previousAddress)) select.value = previousAddress; + select.disabled = !candidates.length; + addWireless.disabled = !candidates.length || sources.length >= MAX_SOURCES; + }; + addWireless.onclick = () => { + const descriptor = candidates.find((candidate) => candidate.address === select.value); + if (!descriptor || sources.length >= MAX_SOURCES || hasMergeSource(sources, { address: descriptor.address })) return; + const state = stateFor(node); + let ordinal = state.nextOrdinal; + const used = new Set(sources.map((source) => source.key)); + while (used.has(`pipe_${String(ordinal).padStart(3, "0")}`)) ordinal++; + state.nextOrdinal = ordinal + 1; + sources.push({ key: `pipe_${String(ordinal).padStart(3, "0")}`, mode: "wireless", address: descriptor.address, label: descriptor.route.name || descriptor.node.title || "Pipe" }); + render(); + }; + addRow.append(select, addWireless); + panel.append(addRow); + refreshDropdown(); + + const actions = document.createElement("div"); + actions.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:16px"; + const cancel = document.createElement("button"); cancel.textContent = "Schließen"; cancel.onclick = () => overlay.remove(); + const save = document.createElement("button"); save.textContent = "Speichern"; + save.onclick = () => { reconcileInputs(node); overlay.remove(); }; + actions.append(cancel, save); + panel.append(actions); + overlay.onclick = (event) => { if (event.target === overlay) overlay.remove(); }; + document.body.append(overlay); +} + +function setupNode(node) { + if (node.__bvSmartPipeMergeReady) return; + node.__bvSmartPipeMergeReady = true; + ensureUniqueId(node); + const state = stateFor(node); + node.title = state.name || SMART_PIPE_MERGE_DEFAULT_TITLE; + node.__bvSmartPipeMergeAppliedTitle = node.title; + const button = node.addWidget?.("button", "Configure Merge", null, () => openEditor(node), { serialize: false }); + if (button) button.label = "Configure Pipe Merge"; + const originalConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function (type, index, connected) { + originalConnectionsChange?.apply(this, arguments); + if (type === 1 && connected && convertAddSource(this, index, arguments[3])) return; + requestAnimationFrame(() => reconcileInputs(this)); + }; + requestAnimationFrame(() => reconcileInputs(node)); +} + +app.registerExtension({ + name: "bv_nodepack.smart_pipe_merge", + init() { + const style = document.createElement("style"); + style.textContent = `.dom-widget:has(textarea[placeholder="${CONFIG_INPUT}"]) { display:none!important; }`; + document.head.append(style); + }, + async nodeCreated(node) { + if (isMerge(node)) setupNode(node); + }, + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_CLASS) return; + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = originalConfigure?.apply(this, arguments); + setupNode(this); + requestAnimationFrame(() => reconcileInputs(this)); + return result; + }; + const originalMenu = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (_, options) { + originalMenu?.apply(this, arguments); + options.unshift({ content: "Configure Pipe Merge", callback: () => openEditor(this) }); + }; + }, +}); diff --git a/js/bv_smart_pipe_merge_model.js b/js/bv_smart_pipe_merge_model.js new file mode 100644 index 0000000..a471a87 --- /dev/null +++ b/js/bv_smart_pipe_merge_model.js @@ -0,0 +1,84 @@ +export const SMART_PIPE_DEFAULT_TITLE = "🌀 BV Smart Pipe"; +export const SMART_PIPE_MERGE_DEFAULT_TITLE = "🌀 BV Smart Pipe Merge"; + +export function mergeSourceIdentity(source) { + return String(source?.address || "").trim() || null; +} + +export function hasMergeSource(sources, candidate) { + const identity = mergeSourceIdentity(candidate); + return Boolean(identity && (sources || []).some((source) => mergeSourceIdentity(source) === identity)); +} + +export function normalizeMergeSources(sources, connectedWiredKeys = new Set()) { + const normalized = []; + const seenKeys = new Set(); + const seenAddresses = new Set(); + for (const source of sources || []) { + const key = String(source?.key || "").trim(); + const mode = source?.mode === "wired" ? "wired" : "wireless"; + const address = mergeSourceIdentity(source); + if (!/^pipe_\d+$/.test(key) || seenKeys.has(key)) continue; + if (mode === "wired" && !connectedWiredKeys.has(key)) continue; + if (mode === "wireless" && !address) continue; + if (address && seenAddresses.has(address)) continue; + seenKeys.add(key); + if (address) seenAddresses.add(address); + normalized.push(source); + } + return normalized; +} + +export function duplicateDynamicInputIndexes(inputs) { + const groups = new Map(); + (inputs || []).forEach((input, index) => { + if (!/^pipe_\d+$/.test(input?.name || "")) return; + const indexes = groups.get(input.name) || []; + indexes.push(index); + groups.set(input.name, indexes); + }); + const duplicates = []; + for (const indexes of groups.values()) { + if (indexes.length < 2) continue; + const keep = indexes.find((index) => inputs[index]?.link != null) ?? indexes[0]; + duplicates.push(...indexes.filter((index) => index !== keep)); + } + return duplicates.sort((left, right) => right - left); +} + +export function mergePipeSchemas(schemas) { + const order = []; + const byId = new Map(); + const canonicalIdById = new Map(); + const normalizedName = (slot) => String(slot?.name || "").trim().toLocaleLowerCase(); + const compatibleTypes = (left, right) => !left || !right || left === "*" || right === "*" || left === right; + for (const schema of schemas || []) { + for (const slot of schema || []) { + if (!slot?.id) continue; + let canonicalId = canonicalIdById.get(slot.id); + if (!canonicalId) { + canonicalId = order.find((id) => { + const candidate = byId.get(id); + return normalizedName(candidate) === normalizedName(slot) + && compatibleTypes(candidate?.type, slot.type); + }) || slot.id; + canonicalIdById.set(slot.id, canonicalId); + } + if (!byId.has(canonicalId)) { + order.push(canonicalId); + byId.set(canonicalId, { ...slot, id: canonicalId, aliases: [] }); + continue; + } + const current = byId.get(canonicalId); + const aliases = new Set([...(current.aliases || []), ...(slot.aliases || [])]); + if (slot.id !== canonicalId) aliases.add(slot.id); + const type = current.type === "*" && slot.type && slot.type !== "*" ? slot.type : current.type; + byId.set(canonicalId, { ...current, type, aliases: [...aliases] }); + } + } + return order.map((id, index) => ({ ...byId.get(id), ordinal: index + 1 })); +} + +export function compactMergeNodeHeight(wiredSourceCount) { + return 88 + Math.max(0, Number(wiredSourceCount) || 0) * 22; +} diff --git a/js/bv_smart_pipe_routing.js b/js/bv_smart_pipe_routing.js new file mode 100644 index 0000000..f97c6b9 --- /dev/null +++ b/js/bv_smart_pipe_routing.js @@ -0,0 +1,443 @@ +export const ROUTE_INPUT = "bv_smart_pipe_route_json"; +export const ROOT_ROUTING_PROPERTY = "bvSmartPipeRootId"; +export const ROUTE_REGISTRY_PROPERTY = "bvSmartPipeRoutes"; +export const HOST_ROUTING_PROPERTY = "bvSmartPipeHost"; + +export function logicalAddress(rootId, hostPath, nodeId) { + return [rootId, ...(hostPath || []), nodeId].map((part) => encodeURIComponent(String(part))).join("/"); +} + +export function parseLogicalAddress(address) { + return String(address || "").split("/").filter(Boolean).map((part) => decodeURIComponent(part)); +} + +export function cloneRouteRegistryPrefix(registry, oldPrefix, newPrefix) { + const normalizedOld = `${String(oldPrefix).replace(/\/$/, "")}/`; + const normalizedNew = `${String(newPrefix).replace(/\/$/, "")}/`; + const additions = []; + for (const [destination, edge] of Object.entries(registry || {})) { + if (!destination.startsWith(normalizedOld)) continue; + const clonedDestination = `${normalizedNew}${destination.slice(normalizedOld.length)}`; + if (registry[clonedDestination]) continue; + const cloned = typeof structuredClone === "function" ? structuredClone(edge) : JSON.parse(JSON.stringify(edge)); + if (cloned.predecessorAddress?.startsWith(normalizedOld)) { + cloned.predecessorAddress = `${normalizedNew}${cloned.predecessorAddress.slice(normalizedOld.length)}`; + } + additions.push([clonedDestination, cloned]); + } + for (const [destination, edge] of additions) registry[destination] = edge; + return additions.length; +} + +export function reconcileRouteRegistryDestinations(registry, liveAddresses, state, now, graceMs = 1000) { + state.missingSince ??= new Map(); + state.backups ??= new Map(); + let removed = 0; + let restored = 0; + for (const address of liveAddresses) { + state.missingSince.delete(address); + if (!registry[address] && state.backups.has(address)) { + registry[address] = state.backups.get(address); + state.backups.delete(address); + restored++; + } + } + for (const [address, edge] of Object.entries(registry || {})) { + if (liveAddresses.has(address)) continue; + const missingSince = state.missingSince.get(address); + if (missingSince == null) { + state.missingSince.set(address, now); + continue; + } + if (now - missingSince < graceMs) continue; + state.backups.set(address, edge); + state.missingSince.delete(address); + delete registry[address]; + removed++; + } + return { removed, restored }; +} + +export function uniqueHostName(baseName, usedNames) { + return uniqueRoutingName(baseName || "Subgraph", usedNames); +} + +export function detachedHostRouting(value) { + return { ...(value || {}) }; +} + +export function preferredHostRoutingName({ storedName, storedDefinitionName, title, lastAppliedTitle, definitionName }) { + const definitionWasRenamed = Boolean(storedDefinitionName && definitionName && storedDefinitionName !== definitionName); + const legacyDefaultName = !storedDefinitionName && /^New Subgraph(?:_\d+)?$/.test(storedName || title || ""); + if (definitionWasRenamed || (legacyDefaultName && definitionName)) return definitionName; + if (lastAppliedTitle && title && title !== lastAppliedTitle) return title; + return storedName || title || definitionName || "Subgraph"; +} + +export function routingChoiceLabel(number, name) { + const parts = String(number).split("."); + const position = Math.max(1, Number.parseInt(parts[1] || "1", 10) || 1); + const level = Math.max(0, position - 1) + Math.max(0, parts.length - 2); + return `${"\u00a0".repeat(level * 3)}${number}. ${name}`; +} + +export function compareRoutingNumbers(left, right) { + const leftParts = String(left).split(".").map((part) => Number.parseInt(part, 10) || 0); + const rightParts = String(right).split(".").map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index++) { + const difference = (leftParts[index] || 0) - (rightParts[index] || 0); + if (difference) return difference; + } + return 0; +} + +export function uniqueRoutingName(baseName, usedNames) { + const base = String(baseName || "Pipe").trim() || "Pipe"; + if (!usedNames.has(base)) return base; + let suffix = 1; + while (usedNames.has(`${base}_${suffix}`)) suffix++; + return `${base}_${suffix}`; +} + +export function routingCollisionOwner(left, right) { + const leftInitialized = Boolean(left?.__bvLastRoutingTitle); + const rightInitialized = Boolean(right?.__bvLastRoutingTitle); + if (leftInitialized !== rightInitialized) return leftInitialized ? left : right; + const graph = left?.graph && left.graph === right?.graph ? left.graph : null; + const nodes = graph?._nodes || graph?.nodes || []; + const leftIndex = nodes.indexOf(left); + const rightIndex = nodes.indexOf(right); + if (leftIndex >= 0 && rightIndex >= 0 && leftIndex !== rightIndex) { + return leftIndex < rightIndex ? left : right; + } + return String(left?.id ?? "") <= String(right?.id ?? "") ? left : right; +} + +export function executionScope(executionId) { + const parts = String(executionId).split(":"); + parts.pop(); + return parts.join(":"); +} + +export function crossScopeDescriptors(descriptors, ownDescriptor) { + const ownPath = ownDescriptor?.hostPath || []; + return (descriptors || []).filter((descriptor) => { + if (!descriptor || descriptor.address === ownDescriptor?.address) return false; + const path = descriptor.hostPath || []; + return path.length !== ownPath.length || path.some((part, index) => part !== ownPath[index]); + }); +} + +export function routingDisplayNumbers(routes) { + const byId = new Map(routes.map((route) => [route.nodeId, route])); + const routeIndexes = new Map(routes.map((route, index) => [route.nodeId, index])); + const routeSort = (left, right) => String(left.name).localeCompare(String(right.name)) || String(left.nodeId).localeCompare(String(right.nodeId)); + const graphOrderSort = (left, right) => (routeIndexes.get(left.nodeId) ?? Number.MAX_SAFE_INTEGER) + - (routeIndexes.get(right.nodeId) ?? Number.MAX_SAFE_INTEGER) || routeSort(left, right); + const rootById = new Map(); + const rootOf = (route, visiting = new Set()) => { + if (rootById.has(route.nodeId)) return rootById.get(route.nodeId); + if (route.mode === "merge" || route.mode !== "follow" || !byId.has(route.predecessorId) || visiting.has(route.nodeId)) { + rootById.set(route.nodeId, route); + return route; + } + visiting.add(route.nodeId); + const root = rootOf(byId.get(route.predecessorId), visiting); + visiting.delete(route.nodeId); + rootById.set(route.nodeId, root); + return root; + }; + routes.forEach((route) => rootOf(route)); + const roots = [...new Map(routes.map((route) => { + const root = rootById.get(route.nodeId); + return [root.nodeId, root]; + })).values()]; + const dependencies = new Map(roots.map((root) => [root.nodeId, new Set()])); + for (const root of roots) { + if (root.mode !== "merge") continue; + for (const sourceId of root.sourceIds || []) { + const source = byId.get(sourceId); + const sourceRoot = source && rootById.get(source.nodeId); + if (sourceRoot && sourceRoot.nodeId !== root.nodeId) dependencies.get(root.nodeId).add(sourceRoot.nodeId); + } + } + const orderedRoots = []; + const emitted = new Set(); + while (orderedRoots.length < roots.length) { + const ready = roots.filter((root) => !emitted.has(root.nodeId) + && [...dependencies.get(root.nodeId)].every((dependency) => emitted.has(dependency))).sort(graphOrderSort); + const next = ready[0] || roots.filter((root) => !emitted.has(root.nodeId)).sort(graphOrderSort)[0]; + emitted.add(next.nodeId); + orderedRoots.push(next); + } + const rootNumbers = new Map(orderedRoots.map((root, index) => [root.nodeId, index + 1])); + const children = new Map(); + for (const route of routes) { + if (route.mode !== "follow" || !byId.has(route.predecessorId)) continue; + const group = children.get(route.predecessorId) || []; + group.push(route); + children.set(route.predecessorId, group); + } + children.forEach((group) => group.sort(routeSort)); + const labels = new Map(); + const labelChildren = (route, tree, depth, branchPath, visiting = new Set()) => { + if (visiting.has(route.nodeId)) return; + visiting.add(route.nodeId); + labels.set(route.nodeId, [tree, depth, ...branchPath].join(".")); + const descendants = children.get(route.nodeId) || []; + descendants.forEach((child, index) => { + if (descendants.length === 1) labelChildren(child, tree, depth + 1, branchPath, visiting); + else labelChildren(child, tree, depth, [...branchPath, index + 1], visiting); + }); + visiting.delete(route.nodeId); + }; + for (const root of orderedRoots) labelChildren(root, rootNumbers.get(root.nodeId), 1, []); + return labels; +} + +export function predecessorChoiceRoutes(routes, destinationId) { + const blocked = new Set(destinationId ? [destinationId] : []); + let changed = true; + while (changed) { + changed = false; + for (const route of routes || []) { + if (blocked.has(route.nodeId)) continue; + const dependsOnBlocked = route.mode === "follow" + ? blocked.has(route.predecessorId) + : route.mode === "merge" && (route.sourceIds || []).some((sourceId) => blocked.has(sourceId)); + if (!dependsOnBlocked) continue; + blocked.add(route.nodeId); + changed = true; + } + } + return (routes || []).filter((route) => !blocked.has(route.nodeId)); +} + +export function promptModeState(descriptors = []) { + const muted = descriptors.filter((descriptor) => descriptor?.node?.mode === 2); + return { + bypassedAddresses: new Set(descriptors.filter((descriptor) => descriptor?.kind === "pipe" && descriptor?.node?.mode === 4) + .map((descriptor) => descriptor.address)), + mutedAddresses: new Set(muted.map((descriptor) => descriptor.address)), + prunedExecutionIds: new Set(muted.map((descriptor) => descriptor.executionId)), + }; +} + +function parseRoute(entry, executionId) { + const raw = entry?.inputs?.[ROUTE_INPUT]; + if (typeof raw !== "string") return null; + try { + const route = JSON.parse(raw); + if (!route?.nodeId) return null; + return { executionId, entry, route, scope: executionScope(executionId) }; + } catch { + throw new Error(`BV Smart Pipe ${executionId}: Routing metadata is invalid.`); + } +} + +export function materializeAddressedPipeLinks(apiPrompt, addressByExecutionId, routeRegistry = {}, modeState = {}) { + const executionByAddress = new Map(); + for (const [executionId, address] of Object.entries(addressByExecutionId || {})) { + if (executionByAddress.has(address)) throw new Error(`Duplicate BV Smart Pipe address: ${address}`); + executionByAddress.set(address, executionId); + } + const bypassedAddresses = modeState.bypassedAddresses || new Set(); + const mutedAddresses = modeState.mutedAddresses || new Set(); + const resolvePredecessor = (destinationAddress, predecessorAddress, visited = new Set()) => { + if (mutedAddresses.has(predecessorAddress)) { + return null; + } + if (bypassedAddresses.has(predecessorAddress)) { + if (visited.has(predecessorAddress)) { + throw new Error(`BV Smart Pipe "${destinationAddress}": Bypass cycle detected at "${predecessorAddress}".`); + } + visited.add(predecessorAddress); + const bypassRoute = routeRegistry?.[predecessorAddress]; + if (!bypassRoute?.predecessorAddress) { + throw new Error(`BV Smart Pipe "${destinationAddress}": Bypassed predecessor "${predecessorAddress}" has no predecessor to pass through.`); + } + return resolvePredecessor(destinationAddress, bypassRoute.predecessorAddress, visited); + } + const predecessorId = executionByAddress.get(predecessorAddress); + if (!predecessorId || !["BV Smart Pipe", "BV Smart Pipe Merge"].includes(apiPrompt?.[predecessorId]?.class_type)) { + throw new Error(`BV Smart Pipe "${destinationAddress}": Cross-scope predecessor "${predecessorAddress}" is missing.`); + } + return predecessorId; + }; + const edges = new Map(); + for (const [destinationAddress, route] of Object.entries(routeRegistry || {})) { + const destinationId = executionByAddress.get(destinationAddress); + if (!destinationId || !route?.predecessorAddress) continue; + const destination = apiPrompt?.[destinationId]; + if (!destination || destination.class_type !== "BV Smart Pipe" || destination.inputs?.pipe != null) continue; + const predecessorId = resolvePredecessor(destinationAddress, route.predecessorAddress); + if (!predecessorId) { + modeState.prunedExecutionIds?.add(destinationId); + continue; + } + edges.set(destinationId, predecessorId); + } + const visiting = new Set(); + const visited = new Set(); + const visit = (executionId) => { + if (visited.has(executionId)) return; + if (visiting.has(executionId)) throw new Error(`BV Smart Pipe "${executionId}": Cross-scope cycle detected.`); + visiting.add(executionId); + const predecessorId = edges.get(executionId); + if (predecessorId) visit(predecessorId); + visiting.delete(executionId); + visited.add(executionId); + }; + edges.forEach((_, destinationId) => visit(destinationId)); + for (const [destinationId, predecessorId] of edges) { + apiPrompt[destinationId].inputs.pipe = [predecessorId, 0]; + } + return apiPrompt; +} + +export function materializeSmartPipeMergeSources(apiPrompt, addressByExecutionId, routeRegistry = {}, modeState = {}) { + const executionByAddress = new Map(Object.entries(addressByExecutionId || {}).map(([executionId, address]) => [address, executionId])); + const mutedAddresses = modeState.mutedAddresses || new Set(); + for (const [destinationAddress, route] of Object.entries(routeRegistry || {})) { + if (route?.kind !== "merge" || !Array.isArray(route.sources)) continue; + const destinationId = executionByAddress.get(destinationAddress); + const destination = apiPrompt?.[destinationId]; + if (!destination || destination.class_type !== "BV Smart Pipe Merge") continue; + for (const source of route.sources) { + if (!source?.key) continue; + if (mutedAddresses.has(source.address)) { + delete destination.inputs[source.key]; + continue; + } + if (source.mode !== "wireless") continue; + if (destination.inputs?.[source.key] != null) continue; + const predecessorId = executionByAddress.get(source.address); + if (!predecessorId || !["BV Smart Pipe", "BV Smart Pipe Merge"].includes(apiPrompt?.[predecessorId]?.class_type)) { + throw new Error(`BV Smart Pipe Merge "${destinationAddress}": Wireless source "${source.address || source.label || source.key}" is missing.`); + } + destination.inputs[source.key] = [predecessorId, 0]; + } + destination.inputs.bv_smart_pipe_merge_json = JSON.stringify(route.sources.filter((source) => !mutedAddresses.has(source.address))); + if (route.sources.length && route.sources.every((source) => mutedAddresses.has(source.address))) { + modeState.prunedExecutionIds?.add(destinationId); + } + } + return apiPrompt; +} + +function resolveLocalPredecessor(record, byScope, modeState = {}, visited = new Set()) { + const key = `${record.scope}\u0000${record.route.predecessorId || ""}`; + const predecessor = byScope.get(key); + if (predecessor) return predecessor; + if (modeState.mutedScopedNodeIds?.has(key)) { + return null; + } + if (modeState.bypassedScopedNodeIds?.has(key)) { + if (visited.has(key)) throw new Error(`BV Smart Pipe "${record.route.name}": Bypass cycle detected.`); + visited.add(key); + const explicitPredecessor = modeState.bypassPredecessorsByScopedNodeId?.get(key); + if (explicitPredecessor) return explicitPredecessor; + const route = modeState.routesByScopedNodeId?.get(key); + if (!route?.predecessorId) { + throw new Error(`BV Smart Pipe "${record.route.name}": Bypassed predecessor has no predecessor to pass through.`); + } + return resolveLocalPredecessor({ ...record, route }, byScope, modeState, visited); + } + throw new Error(`BV Smart Pipe "${record.route.name}": Wireless predecessor is missing.`); +} + +export function validateWirelessRecords(records, modeState = {}) { + const byScope = new Map(); + for (const record of records) { + const key = `${record.scope}\u0000${record.route.nodeId}`; + if (byScope.has(key)) throw new Error(`Duplicate BV Smart Pipe ID for "${record.route.name || record.route.nodeId}".`); + byScope.set(key, record); + } + + for (const record of records) { + if (record.entry.inputs?.pipe != null || record.route.mode !== "follow") continue; + resolveLocalPredecessor(record, byScope, modeState); + } + + const visiting = new Set(); + const visited = new Set(); + const visit = (record) => { + const key = `${record.scope}\u0000${record.route.nodeId}`; + if (visited.has(key) || record.entry.inputs?.pipe != null || record.route.mode !== "follow") return; + if (visiting.has(key)) throw new Error(`BV Smart Pipe "${record.route.name}": Wireless cycle detected.`); + visiting.add(key); + const predecessor = byScope.get(`${record.scope}\u0000${record.route.predecessorId}`); + if (predecessor) visit(predecessor); + visiting.delete(key); + visited.add(key); + }; + records.forEach(visit); + return byScope; +} + +export function materializeWirelessPipeLinks(apiPrompt, modeState = {}) { + const records = Object.entries(apiPrompt || {}) + .filter(([, entry]) => entry?.class_type === "BV Smart Pipe") + .map(([executionId, entry]) => parseRoute(entry, executionId)) + .filter(Boolean); + const byScope = validateWirelessRecords(records, modeState); + for (const record of records) { + if (record.entry.inputs?.pipe != null || record.route.mode !== "follow") continue; + const predecessor = resolveLocalPredecessor(record, byScope, modeState); + if (!predecessor) modeState.prunedExecutionIds?.add(record.executionId); + else record.entry.inputs.pipe = [predecessor.executionId, 0]; + } + return apiPrompt; +} + +export function prunePromptBranches(apiPrompt, initialExecutionIds = new Set()) { + const pruned = new Set([...initialExecutionIds].map(String)); + let changed = true; + while (changed) { + changed = false; + for (const [executionId, entry] of Object.entries(apiPrompt || {})) { + if (pruned.has(executionId)) continue; + const dependsOnPruned = Object.values(entry?.inputs || {}).some((value) => Array.isArray(value) && pruned.has(String(value[0]))); + if (!dependsOnPruned) continue; + pruned.add(executionId); + changed = true; + } + } + for (const executionId of pruned) delete apiPrompt?.[executionId]; + return apiPrompt; +} + +export function validateMaterializedPipeGraph(apiPrompt) { + const pipeEntries = new Map(Object.entries(apiPrompt || {}).filter(([, entry]) => ["BV Smart Pipe", "BV Smart Pipe Merge"].includes(entry?.class_type))); + const visiting = new Set(); + const visited = new Set(); + const visit = (executionId) => { + if (visited.has(executionId)) return; + if (visiting.has(executionId)) throw new Error(`BV Smart Pipe "${executionId}": Cycle detected across wired and wireless connections.`); + visiting.add(executionId); + const inputs = pipeEntries.get(executionId)?.inputs || {}; + for (const [name, value] of Object.entries(inputs)) { + if (name !== "pipe" && !/^pipe_\d+$/.test(name)) continue; + const predecessorId = Array.isArray(value) ? String(value[0]) : null; + if (predecessorId && pipeEntries.has(predecessorId)) visit(predecessorId); + } + visiting.delete(executionId); + visited.add(executionId); + }; + pipeEntries.forEach((_, executionId) => visit(executionId)); + return apiPrompt; +} + +export function remapPromptOutputLinks(apiPrompt, outputIndexMaps) { + for (const entry of Object.values(apiPrompt || {})) { + for (const [inputName, value] of Object.entries(entry?.inputs || {})) { + if (!Array.isArray(value) || value.length < 2) continue; + const map = outputIndexMaps?.[String(value[0])]; + const remapped = map?.[String(value[1])]; + if (remapped == null || remapped === value[1]) continue; + entry.inputs[inputName] = [value[0], remapped]; + } + } + return apiPrompt; +} diff --git a/js/bv_smart_pipe_slots.js b/js/bv_smart_pipe_slots.js new file mode 100644 index 0000000..ce8c0d3 --- /dev/null +++ b/js/bv_smart_pipe_slots.js @@ -0,0 +1,95 @@ +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 }; +} + +export function moveMarkedPortToEnd(ports, predicate) { + const index = ports?.findIndex(predicate) ?? -1; + if (index < 0 || index === ports.length - 1) return ports; + ports.push(ports.splice(index, 1)[0]); + return ports; +} + +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)); +} + +export function retainedMissingSlots(previous, upstreamIds, isUsed) { + return previous.filter((slot) => !upstreamIds.has(slot.id) && isUsed(slot)); +} + +export function promoteInheritedSlot(state, slotId, inferredType) { + const inherited = state.inheritedSlots?.find((slot) => slot.id === slotId); + if (!inherited) return null; + const local = { + ...inherited, + type: inherited.type === "*" && inferredType && inferredType !== "*" ? inferredType : inherited.type || inferredType || "*", + showInput: true, + showOutput: inherited.showOutput ?? false, + missing: false, + }; + state.inheritedSlots = state.inheritedSlots.filter((slot) => slot.id !== slotId); + state.localSlots.push(local); + return local; +} + +export function promoteConnectedInheritedSlots(state, connectedSlotIds) { + const promoted = []; + for (const slot of [...(state.inheritedSlots || [])]) { + if (connectedSlotIds.has(slot.id)) promoted.push(promoteInheritedSlot(state, slot.id)); + } + return promoted.filter(Boolean); +} + +export function smartPipeSlotName(value, fallback = "new_slot") { + const normalized = String(value || "") + .trim() + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized || fallback; +} + +export function uniqueSmartPipeSlotName(base, usedNames) { + if (!usedNames.has(base)) return base; + let suffix = 2; + while (usedNames.has(`${base}_${suffix}`)) suffix++; + return `${base}_${suffix}`; +} + +export function resolveLocalSlotNameCollisions(localSlots, reservedNames) { + const usedNames = new Set(reservedNames); + const renamed = []; + for (const slot of localSlots) { + const original = slot.name; + slot.name = uniqueSmartPipeSlotName(original, usedNames); + usedNames.add(slot.name); + if (slot.name !== original) renamed.push({ id: slot.id, from: original, to: slot.name }); + } + return renamed; +} + +export function nextFreeOrdinal(usedOrdinals, start, maximum) { + let ordinal = Math.max(1, start); + while (ordinal <= maximum && usedOrdinals.has(ordinal)) ordinal++; + return ordinal <= maximum ? ordinal : undefined; +} diff --git a/js/bv_subgraph_divider.js b/js/bv_subgraph_divider.js index 164ebe6..462a495 100644 --- a/js/bv_subgraph_divider.js +++ b/js/bv_subgraph_divider.js @@ -21,12 +21,14 @@ function patchDivider(node) { const thickW = getWidget(node, "thickness"); if (!node || !thickW) return; + node.__bvPresentationWidget = thickW; + // Patch per widget instance; allow re-patching if rebuilt if (!thickW.__bvDividerPatched) { thickW.__bvDividerPatched = true; thickW.__bvNode = node; - thickW.type = "BV_DIVIDER"; + thickW.__bvPresentationType = "BV_DIVIDER"; thickW.options = thickW.options || {}; thickW.options.serialize = true; diff --git a/js/bv_subgraph_dynamic_combo.js b/js/bv_subgraph_dynamic_combo.js index e59e57e..4b0030e 100644 --- a/js/bv_subgraph_dynamic_combo.js +++ b/js/bv_subgraph_dynamic_combo.js @@ -1,160 +1,77 @@ import { app } from "../../scripts/app.js"; +import { normalizeDynamicComboSelection, parseDynamicComboOptions } from "./bv_dynamic_combo_model.js"; -/** - * BV Util Dynamic Combo - * - Replaces a STRING widget named "value" with a real "combo" widget. - * - Combo options are taken from a multiline STRING widget named "options". - * - Works also when node is exposed via subgraph (hooks onNodeCreated + onConfigure). - */ +const NODE_CLASS = "BV Dynamic Combo"; -function parseOptions(text) { - const lines = (text ?? "") - .split(/\r?\n/) - .map((s) => s.trim()) - .filter(Boolean); +function valueWidgetFor(node) { + const visible = node.widgets?.find((widget) => widget.name === "value"); + if (visible) return visible; + const input = node.inputs?.find((candidate) => candidate.name === "value"); + return node.getWidgetFromSlot?.(input) ?? null; +} - // unique, order-preserving - const seen = new Set(); - const out = []; - for (const l of lines) { - if (!seen.has(l)) { - seen.add(l); - out.push(l); +function upgrade(node) { + let valueWidget = valueWidgetFor(node); + const optionsWidget = node.widgets?.find((widget) => widget.name === "options"); + if (!valueWidget || !optionsWidget) return; + + if (valueWidget.type !== "combo") { + const previousValue = valueWidget.value; + node.removeWidget?.(valueWidget); + const combo = node.addWidget("combo", "value", previousValue, null, { values: parseDynamicComboOptions(optionsWidget.value) }); + valueWidget = combo; + } + const combo = valueWidget; + combo.options ??= {}; + combo.options.values = parseDynamicComboOptions(optionsWidget.value); + combo.value = normalizeDynamicComboSelection(combo.value, combo.options.values); + combo.options.serialize = true; + + if (!optionsWidget.__bvDynamicComboHooked) { + optionsWidget.__bvDynamicComboHooked = true; + const originalCallback = optionsWidget.callback; + optionsWidget.callback = function (value) { + originalCallback?.apply(this, arguments); + const current = valueWidgetFor(node); + if (current) { + const values = parseDynamicComboOptions(value); + current.options ??= {}; + current.options.values = values; + const normalized = normalizeDynamicComboSelection(current.value, values); + if (normalized !== current.value) { + current.value = normalized; + current.callback?.(normalized); } - } - - return out.length ? out : [""]; -} - -function isMultilineStringWidget(w) { - return ( - w && - w.type === "customtext" - ); -} - -function isStringValueWidget(w) { - // value is declared as STRING in python -> litegraph widget is usually type "string" - return w && w.name === "value" && (w.type === "string" || w.type === "text"); -} - -function findWidgets(node) { - const widgets = node?.widgets || []; - const valueWidget = widgets.find((w) => w?.name === "value"); - const optionsWidget = widgets.find((w) => w?.name === "options"); - return { valueWidget, optionsWidget }; -} - -function replaceWidget(node, oldWidget, newWidget) { - const idx = node.widgets.indexOf(oldWidget); - if (idx >= 0) node.widgets.splice(idx, 1, newWidget); -} - -function ensurePropertiesSync(node, key, val) { - node.properties = node.properties || {}; - node.properties[key] = val; -} - -function ensureComboValid(comboWidget) { - const values = comboWidget?.options?.values || []; - if (!values.includes(comboWidget.value)) { - comboWidget.value = values[0] ?? ""; - } -} - -function upgradeDynamicCombo(node) { - if (!node || !node.widgets) return; - - // Avoid patching twice - if (node.__bvDynamicComboPatched) return; - - const { valueWidget, optionsWidget } = findWidgets(node); - if (!valueWidget || !optionsWidget) return; - - // We only patch if: - // - options is multiline string (the editor list) - // - value is a string widget (declared STRING in python) - if (!isMultilineStringWidget(optionsWidget)) return; - if (!isStringValueWidget(valueWidget)) return; - - const values = parseOptions(optionsWidget.value); - - // Create a REAL combo widget - const comboWidget = node.addWidget( - "combo", - "value", - valueWidget.value ?? (values[0] ?? ""), - (v) => { - ensurePropertiesSync(node, "value", v); - }, - { values } - ); - - - // Replace old string widget with our combo widget - replaceWidget(node, valueWidget, comboWidget); - - // Sync properties & validate selection - ensureComboValid(comboWidget); - ensurePropertiesSync(node, "value", comboWidget.value); - - // Update combo values when options change - const originalOptionsCb = optionsWidget.callback; - optionsWidget.callback = (v) => { - originalOptionsCb?.(v); - - const newValues = parseOptions(v); - comboWidget.options.values = newValues; - - ensureComboValid(comboWidget); - ensurePropertiesSync(node, "value", comboWidget.value); - - node.setDirtyCanvas(true, true); + window.dispatchEvent(new CustomEvent("bv-dynamic-combo-options-changed", { + detail: { sourceNode: node, values }, + })); + } + node.setDirtyCanvas?.(true, true); }; - - node.__bvDynamicComboPatched = true; - node.setDirtyCanvas(true, true); + } } app.registerExtension({ - name: "bv_nodepack.bv_util_dynamic_combo", - - async setup() { - // Patch existing nodes (e.g., after reload) - try { - const graph = app?.graph; - const nodes = graph?._nodes || graph?.nodes || []; - for (const n of nodes) upgradeDynamicCombo(n); - } catch (e) { - // ignore - } - }, - - async beforeRegisterNodeDef(nodeType, nodeData) { - // Patch ALL nodes (important for subgraph-exposed nodes whose name differs) - const origCreated = nodeType.prototype.onNodeCreated; - nodeType.prototype.onNodeCreated = function () { - const r = origCreated?.apply(this, arguments); - try { - upgradeDynamicCombo(this); - } catch (e) { - // ignore - } - return r; - }; - - // Needed for: - // - Loading a workflow - // - Subgraph expose rebuilds - const origConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function () { - const r = origConfigure?.apply(this, arguments); - try { - upgradeDynamicCombo(this); - } catch (e) { - // ignore - } - return r; - }; - }, + name: "bv_nodepack.dynamic_combo", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_CLASS) return; + const originalCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const result = originalCreated?.apply(this, arguments); + upgrade(this); + return result; + }; + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = originalConfigure?.apply(this, arguments); + upgrade(this); + return result; + }; + const originalConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function () { + const result = originalConnectionsChange?.apply(this, arguments); + upgrade(this); + return result; + }; + }, }); diff --git a/js/bv_subgraph_heading.js b/js/bv_subgraph_heading.js index c79eed4..702dd32 100644 --- a/js/bv_subgraph_heading.js +++ b/js/bv_subgraph_heading.js @@ -24,13 +24,20 @@ function patchHeading(node) { if (!node || !valueW) return; + // ComfyUI removes promoted/exposed widgets from node.widgets. Keep the + // presentation object reachable so a Subgraph host can still project it. + node.__bvPresentationWidget = valueW; + // Allow re-patching if widgets were rebuilt: // Only mark patched on the widget instance itself if (!valueW.__bvHeadingPatched) { valueW.__bvHeadingPatched = true; valueW.__bvNode = node; - valueW.type = "BV_HEADING"; + // Keep the native text widget type. Subgraph proxy widgets are rebuilt + // from this type on workflow load; replacing it with BV_HEADING makes + // ComfyUI create a non-editable/empty custom proxy. + valueW.__bvPresentationType = "BV_HEADING"; valueW.options = valueW.options || {}; valueW.options.serialize = true; diff --git a/js/bv_subgraph_layout.js b/js/bv_subgraph_layout.js new file mode 100644 index 0000000..9c7b534 --- /dev/null +++ b/js/bv_subgraph_layout.js @@ -0,0 +1,128 @@ +import { app } from "../../scripts/app.js"; +import { projectSubgraphUIPresentation, synchronizeDynamicComboHost } from "./bv_subgraph_ui_projection.js"; + +const UI_CLASSES = new Set(["BV Subgraph Heading", "BV Subgraph Spacer", "BV Subgraph Divider", "BV Dynamic Combo"]); + +function id() { + return globalThis.crypto?.randomUUID?.() ?? `bv-layout-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function ensureIdentity(node) { + node.properties ??= {}; + const nodes = node.graph?._nodes || node.graph?.nodes || []; + const duplicate = nodes.some((candidate) => candidate !== node && candidate.properties?.bvLayoutId === node.properties.bvLayoutId); + if (!node.properties.bvLayoutId || duplicate) node.properties.bvLayoutId = id(); + if (!Number.isFinite(node.properties.bvLayoutOrder)) { + const orders = nodes.map((candidate) => candidate.properties?.bvLayoutOrder).filter(Number.isFinite); + node.properties.bvLayoutOrder = orders.length ? Math.max(...orders) + 1 : 0; + } +} + +function refreshHost(host) { + const project = () => projectSubgraphUIPresentation(host); + queueMicrotask(() => requestAnimationFrame(() => { + project(); + // Subgraph proxy widgets are finalized one render pass after graph + // configuration. Reapply to the authoritative proxies, not the temporary + // objects present during nodeCreated/configure. + requestAnimationFrame(project); + })); + // Workflow restore finalizes promoted proxies asynchronously after + // afterConfigureGraph (and later than an ordinary widget edit). Use a bounded + // stabilization window so the final proxy receives the presentation too. + for (const delay of [50, 150, 400, 1000]) setTimeout(project, delay); +} + +function attachHost(host) { + const isSubgraphHost = host?.isSubgraphNode?.() || Boolean(host?.subgraph); + if (!isSubgraphHost) return; + if (host.onDrawBackground !== host.__bvSubgraphPresentationRenderGuard) { + const originalDrawBackground = host.onDrawBackground; + const renderGuard = function () { + projectSubgraphUIPresentation(this); + return originalDrawBackground?.apply(this, arguments); + }; + host.__bvSubgraphPresentationRenderGuard = renderGuard; + host.onDrawBackground = renderGuard; + } + if (host.__bvSubgraphUIPresentationAttached) { + refreshHost(host); + return; + } + host.__bvSubgraphUIPresentationAttached = true; + const refresh = () => refreshHost(host); + for (const eventName of ["widget-promoted", "widget-demoted", "input-added", "removing-input", "configured"]) { + host.subgraph?.events?.addEventListener?.(eventName, refresh); + } + refreshHost(host); +} + +function attachGraph(graph, visited = new Set()) { + if (!graph || visited.has(graph)) return; + visited.add(graph); + for (const node of graph._nodes || graph.nodes || []) { + if (!node?.isSubgraphNode?.()) continue; + attachHost(node); + attachGraph(node.subgraph, visited); + } +} + +function synchronizeDynamicComboGraph(graph, sourceNode, values, visited = new Set()) { + if (!graph || visited.has(graph)) return; + visited.add(graph); + for (const node of graph._nodes || graph.nodes || []) { + if (!node?.isSubgraphNode?.()) continue; + synchronizeDynamicComboHost(node, sourceNode, values); + synchronizeDynamicComboGraph(node.subgraph, sourceNode, values, visited); + } +} + +app.registerExtension({ + name: "bv_nodepack.subgraph_layout_identity", + async nodeCreated(node) { + if (UI_CLASSES.has(node.comfyClass)) ensureIdentity(node); + if (node?.isSubgraphNode?.()) attachHost(node); + }, + setup() { + // setup() runs before app.graph is guaranteed to exist. Accessing the + // throwing getter here aborts the extension and prevents every listener + // below from being installed. + app.canvas?.addEventListener?.("litegraph:set-graph", (event) => attachGraph((event.detail || event).newGraph)); + app.canvas?.addEventListener?.("subgraph-opened", (event) => { + const detail = event.detail || event; + attachHost(detail.fromNode); + attachGraph(detail.subgraph); + }); + window.addEventListener("bv-dynamic-combo-options-changed", (event) => { + const { sourceNode, values } = event.detail || {}; + const rootGraph = sourceNode?.graph?.rootGraph; + if (sourceNode && Array.isArray(values)) synchronizeDynamicComboGraph(rootGraph, sourceNode, values); + }); + + // Workflow tabs can replace the active graph without configuring it again. + // Keep a small fallback around the official canvas method as well as the + // event above so already-loaded tab graphs are projected immediately. + const canvas = app.canvas; + if (canvas?.setGraph && !canvas.__bvSubgraphSetGraphWrapped) { + canvas.__bvSubgraphSetGraphWrapped = true; + const originalSetGraph = canvas.setGraph; + canvas.setGraph = function (graph) { + const result = originalSetGraph.apply(this, arguments); + attachGraph(graph); + return result; + }; + } + }, + afterConfigureGraph() { + attachGraph(app.graph); + }, + async beforeRegisterNodeDef(nodeType, nodeData) { + if (!UI_CLASSES.has(nodeData.name)) return; + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = originalConfigure?.apply(this, arguments); + ensureIdentity(this); + return result; + }; + }, +}); diff --git a/js/bv_subgraph_spacer.js b/js/bv_subgraph_spacer.js index c738832..dc8767b 100644 --- a/js/bv_subgraph_spacer.js +++ b/js/bv_subgraph_spacer.js @@ -21,10 +21,12 @@ function patchSpacer(node) { const hW = getWidget(node, "height"); if (!node || !hW || hW.__bvSpacerPatched) return; + node.__bvPresentationWidget = hW; + hW.__bvSpacerPatched = true; hW.__bvNode = node; - hW.type = "BV_SPACER"; + hW.__bvPresentationType = "BV_SPACER"; hW.options = hW.options || {}; hW.options.serialize = true; diff --git a/js/bv_subgraph_ui_projection.js b/js/bv_subgraph_ui_projection.js new file mode 100644 index 0000000..092905e --- /dev/null +++ b/js/bv_subgraph_ui_projection.js @@ -0,0 +1,298 @@ +const PRESENTATION_TYPES = new Set(["BV_HEADING", "BV_SPACER", "BV_DIVIDER"]); +import { normalizeDynamicComboSelection } from "./bv_dynamic_combo_model.js"; +const PRESENTATION_NODE_TYPES = new Map([ + ["BV Subgraph Heading", "BV_HEADING"], + ["BV Subgraph Spacer", "BV_SPACER"], + ["BV Subgraph Divider", "BV_DIVIDER"], +]); +const PRESENTATION_INPUT_NAMES = new Map([ + ["BV_HEADING", "Header"], + ["BV_DIVIDER", "Divider"], + ["BV_SPACER", "Spacer"], +]); + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function migratePresentationInputName(host, input, inputIndex, presentation) { + const desiredBase = PRESENTATION_INPUT_NAMES.get(presentation.presentationType); + const sourceName = presentation.sourceInput?.name; + const subgraphInput = input?._subgraphSlot ?? host.subgraph?.inputs?.[inputIndex]; + if (!desiredBase || !sourceName || !subgraphInput || typeof host.subgraph?.renameInput !== "function") return; + + const currentName = String(subgraphInput.name ?? input?.name ?? ""); + // Preserve deliberate user names. ComfyUI-generated collision suffixes of + // the original technical name still qualify for migration. + const generatedName = new RegExp(`^${escapeRegExp(sourceName)}(?:_\\d+)?$`); + if (!generatedName.test(currentName)) return; + + const usedNames = new Set((host.subgraph.inputs || []) + .filter((candidate) => candidate !== subgraphInput) + .map((candidate) => candidate?.name) + .filter(Boolean)); + let desiredName = desiredBase; + for (let suffix = 1; usedNames.has(desiredName); suffix++) desiredName = `${desiredBase}_${suffix}`; + if (currentName !== desiredName) host.subgraph.renameInput(subgraphInput, desiredName); +} + +function presentationTypeFor(sourceNode, widget) { + const widgetType = widget?.__bvPresentationType ?? widget?.type; + if (PRESENTATION_TYPES.has(widgetType)) return widgetType; + return PRESENTATION_NODE_TYPES.get(sourceNode?.comfyClass) + ?? PRESENTATION_NODE_TYPES.get(sourceNode?.type) + ?? null; +} + +function sourcePresentationFor(host, input, inputIndex) { + const resolvedConnections = host.resolveSubgraphInputLinks?.(inputIndex) || []; + for (const resolved of resolvedConnections) { + const presentation = presentationFromResolved(resolved); + if (presentation) return presentation; + } + + // Legacy hosts attached the definition slot directly to the public input. + // Current ComfyUI keeps it in the subgraph definition at the same index. + const subgraphSlot = input?._subgraphSlot + ?? host.subgraph?.inputs?.[inputIndex] + ?? host.subgraph?.inputs?.find((slot) => slot?.name === input?.name); + if (!subgraphSlot?.linkIds?.length) return null; + for (const linkId of subgraphSlot.linkIds) { + const link = host.subgraph?.getLink?.(linkId); + const resolved = link?.resolve?.(host.subgraph); + const presentation = presentationFromResolved(resolved); + if (presentation) return presentation; + } + return null; +} + +function resolvedInputConnections(host, input, inputIndex) { + const resolved = [...(host.resolveSubgraphInputLinks?.(inputIndex) || [])]; + if (resolved.length) return resolved; + const subgraphSlot = input?._subgraphSlot ?? host.subgraph?.inputs?.[inputIndex]; + for (const linkId of subgraphSlot?.linkIds || []) { + const connection = host.subgraph?.getLink?.(linkId)?.resolve?.(host.subgraph); + if (connection) resolved.push(connection); + } + return resolved; +} + +export function synchronizeDynamicComboHost(host, sourceNode, values) { + let synchronized = 0; + for (const [inputIndex, input] of (host?.inputs || []).entries()) { + const matchesSource = resolvedInputConnections(host, input, inputIndex) + .some((connection) => connection?.inputNode === sourceNode && connection?.input?.name === "value"); + if (!matchesSource) continue; + const target = host.getWidgetFromSlot?.(input); + if (!target) continue; + target.options ??= {}; + target.options.values = [...values]; + const normalized = normalizeDynamicComboSelection(target.value, values); + if (normalized !== target.value) { + target.value = normalized; + target.callback?.(normalized); + } + host.setDirtyCanvas?.(true, true); + host.graph?.setDirtyCanvas?.(true, true); + synchronized++; + } + return synchronized; +} + +function presentationFromResolved(resolved) { + const sourceNode = resolved?.inputNode; + const sourceInput = resolved?.input; + if (!sourceNode || !sourceInput) return null; + if (sourceNode.isSubgraphNode?.()) projectSubgraphUIPresentation(sourceNode); + const widget = sourceNode.getWidgetFromSlot?.(sourceInput) + || sourceNode.__bvPresentationWidget; + const presentationType = presentationTypeFor(sourceNode, widget); + return widget && presentationType + ? { widget, sourceNode, sourceInput, presentationType } + : null; +} + +function presentationKey(sourceNode, sourceInput, input) { + const identity = sourceNode?.properties?.bvLayoutId ?? sourceNode?.id ?? "presentation"; + return `${identity}:${sourceInput?.name ?? input?.name ?? "value"}`; +} + +function reprojectAfterWidgetSync(host) { + const nextFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)); + queueMicrotask(() => nextFrame(() => { + projectSubgraphUIPresentation(host); + nextFrame(() => projectSubgraphUIPresentation(host)); + })); + setTimeout(() => projectSubgraphUIPresentation(host), 100); +} + +function createProjectedWidget(host, input, source, key, presentationType) { + host.properties ??= {}; + host.properties.bvSubgraphUIValues ??= {}; + const saved = host.properties.bvSubgraphUIValues[key]; + const initial = saved === undefined ? source.value : saved; + host.properties.bvSubgraphUIValues[key] = initial; + const callback = (value) => { + host.properties.bvSubgraphUIValues[key] = value; + host.setDirtyCanvas?.(true, true); + host.graph?.setDirtyCanvas?.(true, true); + }; + const editable = presentationType === "BV_HEADING"; + const descriptor = { + name: input.name, + type: editable ? "text" : source.type, + value: initial, + callback, + options: { serialize: true }, + }; + const target = editable + ? host.addWidget?.("text", input.name, initial, callback, descriptor.options) + : host.addCustomWidget?.(descriptor); + if (!target) return null; + target.__bvSyntheticPresentation = true; + target.__bvPresentationKey = key; + return target; +} + +export function projectSubgraphUIPresentation(host, visited = new Set()) { + if (!host || visited.has(host)) return 0; + visited.add(host); + let projected = 0; + let changed = false; + for (const [inputIndex, input] of (host.inputs || []).entries()) { + const presentation = sourcePresentationFor(host, input, inputIndex); + if (!presentation) continue; + const source = presentation.widget; + const presentationType = presentation.presentationType; + migratePresentationInputName(host, input, inputIndex, presentation); + const key = presentationKey(presentation.sourceNode, presentation.sourceInput, input); + const slotWidget = host.getWidgetFromSlot?.(input); + // Current ComfyUI owns promoted proxy widgets through the input slot. Such + // a proxy may intentionally not be present in host.widgets; replacing it + // with a synthetic widget makes ComfyUI remove the replacement during its + // next synchronization pass and also loses the proxy's native editor. + const target = slotWidget + || host.widgets?.find((widget) => widget.widgetId && widget.widgetId === input.widgetId) + || host.widgets?.find((widget) => widget.__bvPresentationKey === key) + || createProjectedWidget(host, input, source, key, presentationType); + if (!target) continue; + // Modern ComfyUI widgets expose type as a getter-only property. Their + // renderer already honours a custom draw/computeSize implementation, so + // changing the type is both unnecessary and invalid. + if (target.draw !== source.draw) { + target.draw = source.draw; + changed = true; + } + const projectedComputeSize = presentationType === "BV_SPACER" && typeof source.computeSize === "function" + ? (target.__bvSpacerComputeSource === source.computeSize && target.__bvSpacerComputeSize) + || function (width) { + const previousValue = source.value; + source.value = this.value; + try { + return source.computeSize.call(source, width); + } finally { + source.value = previousValue; + } + } + : source.computeSize; + if (presentationType === "BV_SPACER") { + target.__bvSpacerComputeSource = source.computeSize; + target.__bvSpacerComputeSize = projectedComputeSize; + if (!target.__bvSpacerCallback || target.callback !== target.__bvSpacerCallback) { + const originalCallback = target.callback; + const spacerCallback = function (value) { + this.value = value; + const result = originalCallback?.apply(this, arguments); + host.expandToFitContent?.(); + const computedSize = host.computeSize?.(); + if (computedSize) host.setSize?.(computedSize); + host.setDirtyCanvas?.(true, true); + host.graph?.setDirtyCanvas?.(true, true); + return result; + }; + target.callback = spacerCallback; + target.__bvSpacerCallback = spacerCallback; + changed = true; + } + } + if (target.computeSize !== projectedComputeSize) { + target.computeSize = projectedComputeSize; + changed = true; + } + if (typeof target.drawWidget === "function" && typeof source.draw === "function") { + if (target.drawWidget !== target.__bvProjectedDrawWidget || target.__bvProjectedDrawSource !== source.draw) { + const projectedDrawWidget = target.__bvProjectedDrawSource === source.draw && target.__bvProjectedDrawWidget + ? target.__bvProjectedDrawWidget + : function (ctx, options = {}) { + const width = options.width ?? this.width ?? host.size?.[0] ?? 220; + const height = this.height ?? this.computeSize?.(width)?.[1] ?? 24; + return source.draw.call(this, ctx, host, width, this.y ?? 0, height, !options.showText); + }; + target.drawWidget = projectedDrawWidget; + target.__bvProjectedDrawWidget = projectedDrawWidget; + target.__bvProjectedDrawSource = source.draw; + changed = true; + } + } + if (typeof source.computeSize === "function") { + if (target.computeLayoutSize !== target.__bvProjectedLayout || target.__bvProjectedLayoutSource !== source.computeSize) { + const projectedLayout = target.__bvProjectedLayoutSource === source.computeSize && target.__bvProjectedLayout + ? target.__bvProjectedLayout + : function () { + const [minWidth, minHeight] = this.computeSize(this.width ?? host.size?.[0] ?? 220); + return { minWidth, minHeight }; + }; + target.computeLayoutSize = projectedLayout; + target.__bvProjectedLayout = projectedLayout; + target.__bvProjectedLayoutSource = source.computeSize; + changed = true; + } + } + if (target.__bvPresentationType !== presentationType) changed = true; + target.__bvPresentationType = presentationType; + target.__bvNode = source.__bvNode; + target.__bvProjectedFrom = source; + target.options ??= {}; + if (target.options.serialize !== true) { + target.options.serialize = true; + changed = true; + } + if (presentationType === "BV_HEADING") { + if (target.onClick !== target.__bvHeadingEditor || target.__bvHeadingEditorKey !== key) { + const headingEditor = function ({ e, node, canvas }) { + canvas?.prompt?.(input.label || input.name || "Heading", String(this.value ?? ""), (value) => { + this.value = String(value ?? ""); + host.properties ??= {}; + host.properties.bvSubgraphUIValues ??= {}; + host.properties.bvSubgraphUIValues[key] = this.value; + this.callback?.(this.value, canvas, node, undefined, e); + // The promoted proxy stores an instance value, while the internal + // widget remains the definition fallback used after demotion. + // Keep both aligned so disconnecting the exposure does not restore + // the original default heading. + source.value = this.value; + presentation.sourceNode?.setDirtyCanvas?.(true, true); + presentation.sourceNode?.graph?.setDirtyCanvas?.(true, true); + host.setDirtyCanvas?.(true, true); + host.graph?.setDirtyCanvas?.(true, true); + reprojectAfterWidgetSync(host); + }, e, false); + }; + target.onClick = headingEditor; + target.__bvHeadingEditor = headingEditor; + target.__bvHeadingEditorKey = key; + changed = true; + } + } + host.serialize_widgets = true; + projected++; + } + if (projected && changed) { + host.expandToFitContent?.(); + const computedSize = host.computeSize?.(); + if (computedSize) host.setSize?.(computedSize); + host.setDirtyCanvas?.(true, true); + host.graph?.setDirtyCanvas?.(true, true); + } + return projected; +} diff --git a/py/nodes/bv_control_center.py b/py/nodes/bv_control_center.py index b0abbd6..175f87d 100644 --- a/py/nodes/bv_control_center.py +++ b/py/nodes/bv_control_center.py @@ -6,17 +6,30 @@ class BVControlCenterNode: @classmethod def INPUT_TYPES(cls): return { - "hidden": { - "config_json": ("STRING", {"default": "{}", "multiline": True}), + "required": { + "bv_control_config_json": ("STRING", {"default": "{}", "multiline": True}), }, } RETURN_TYPES = () OUTPUT_NODE = True FUNCTION = "run" - CATEGORY = "🌀 BV Node Pack/beta/control" + CATEGORY = "🌀 BV Node Pack/control" - def run(self, **kwargs): + @classmethod + def VALIDATE_INPUTS(cls, bv_control_config_json, **kwargs): + try: + config = json.loads(bv_control_config_json) + except (TypeError, json.JSONDecodeError) as error: + return f"BV Control Center config is invalid: {error}" + for control in config.get("controls", []): + if control.get("enabled", True): + unresolved = [entry.get("groupPath") or entry.get("groupTitle") for entry in control.get("assignments", []) if entry.get("unresolved")] + if unresolved: + return f"BV Control Center '{control.get('name', 'Unnamed')}' has unresolved groups: {', '.join(unresolved)}" + return True + + def run(self, bv_control_config_json, **kwargs): return () diff --git a/py/nodes/bv_latent_empty_random_ratio.py b/py/nodes/bv_latent_empty_random_ratio.py new file mode 100644 index 0000000..845b7cb --- /dev/null +++ b/py/nodes/bv_latent_empty_random_ratio.py @@ -0,0 +1,61 @@ +import torch + +import comfy.model_management + +from ..util.latent_dimensions import combine_ratios, dimensions_for_area + + +RESOLUTIONS = ["512", "768", "1024", "1536", "2048", "Custom"] +ALIGNMENTS = ["8", "16", "32", "64"] +STANDARD_RATIOS = ( + ("use_1_1", (1, 1)), + ("use_3_2", (3, 2)), + ("use_2_3", (2, 3)), + ("use_4_3", (4, 3)), + ("use_3_4", (3, 4)), + ("use_16_9", (16, 9)), + ("use_9_16", (9, 16)), + ("use_21_9", (21, 9)), + ("use_9_21", (9, 21)), +) + + +class BVEmptyLatentRandomRatio: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF}), + "resolution": (RESOLUTIONS, {"default": "1024"}), + "custom_resolution": ("INT", {"default": 1024, "min": 64, "max": 16384, "step": 8}), + "alignment": (ALIGNMENTS, {"default": "8"}), + **{name: ("BOOLEAN", {"default": True}) for name, _ratio in STANDARD_RATIOS}, + "ratios": ("STRING", {"default": "", "multiline": True, "placeholder": "Additional ratios, one per line (for example 5:4)"}), + "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096}), + } + } + + RETURN_TYPES = ("LATENT", "INT", "INT", "STRING") + RETURN_NAMES = ("latent", "width", "height", "picked_ratio") + FUNCTION = "generate" + CATEGORY = "🌀 BV Node Pack/latent" + DESCRIPTION = "Creates a native empty latent at a deterministic aspect ratio while preserving the selected square pixel area." + + def generate(self, seed, resolution, custom_resolution, alignment, ratios, batch_size, **enabled_ratios): + standard_ratios = [ratio for name, ratio in STANDARD_RATIOS if enabled_ratios[name]] + parsed_ratios = combine_ratios(standard_ratios, ratios) + picked_ratio = parsed_ratios[int(seed) % len(parsed_ratios)] + square_resolution = int(custom_resolution if resolution == "Custom" else resolution) + alignment_value = int(alignment) + width, height = dimensions_for_area(square_resolution, picked_ratio, alignment_value) + latent = torch.zeros( + [batch_size, 4, height // 8, width // 8], + device=comfy.model_management.intermediate_device(), + dtype=comfy.model_management.intermediate_dtype(), + ) + return ({"samples": latent, "downscale_ratio_spacial": 8}, width, height, f"{picked_ratio[0]}:{picked_ratio[1]}") + + +NODE_CLASS_MAPPINGS = {"BV Empty Latent Random Ratio": BVEmptyLatentRandomRatio} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Empty Latent Random Ratio": "🌀 BV Empty Latent Random Ratio"} diff --git a/py/nodes/bv_latent_random_ratio.py b/py/nodes/bv_latent_random_ratio.py index 80d4649..5d8eb30 100644 --- a/py/nodes/bv_latent_random_ratio.py +++ b/py/nodes/bv_latent_random_ratio.py @@ -60,7 +60,7 @@ class BVLatentRandomAspectRatio: RETURN_TYPES = ("LATENT", "INT", "INT", "STRING") RETURN_NAMES = ("latent", "latent_width", "latent_height", "picked_ratio") FUNCTION = "run" - CATEGORY = "🌀 BV Node Pack/latent" + CATEGORY = "🌀 BV Node Pack/deprecated" def run( self, @@ -172,5 +172,5 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { - "BV Latent Random Aspect Ratio": "🌀 BV Latent Random Aspect Ratio", + "BV Latent Random Aspect Ratio": "🌀 BV Latent Random Aspect Ratio [Deprecated]", } diff --git a/py/nodes/bv_latent_random_rotate_90.py b/py/nodes/bv_latent_random_rotate_90.py index c3a2f73..fc3ea09 100644 --- a/py/nodes/bv_latent_random_rotate_90.py +++ b/py/nodes/bv_latent_random_rotate_90.py @@ -22,40 +22,31 @@ class BVLatentRandomRotate90: CATEGORY = "🌀 BV Node Pack/latent" def run(self, latent, seed: int, enabled: bool): - # Defensive copy of dict-like latent - out = dict(latent) if isinstance(latent, dict) else {"samples": latent} + out = dict(latent) samples = out.get("samples", None) if samples is None: raise Exception("BVLatentRandomRotate90: latent has no 'samples' tensor.") - # samples expected shape: [B, C, H, W] - if not torch.is_tensor(samples) or samples.ndim != 4: - raise Exception(f"BVLatentRandomRotate90: expected samples tensor [B,C,H,W], got {type(samples)} with shape {getattr(samples, 'shape', None)}.") + if not torch.is_tensor(samples) or samples.ndim < 2: + raise Exception(f"BVLatentRandomRotate90: expected a samples tensor with spatial dimensions, got {type(samples)} with shape {getattr(samples, 'shape', None)}.") # Decide rotation do_rotate = bool(enabled) and (int(seed) & 1) == 1 if do_rotate: - # Rotate 90 degrees clockwise: rot90 with k=3 over H,W dims - out["samples"] = torch.rot90(samples, k=3, dims=(2, 3)) + out["samples"] = torch.rot90(samples, k=3, dims=(-2, -1)) # If there's a noise mask, rotate it too (usually [B, H, W] or [B, 1, H, W]) if "noise_mask" in out and out["noise_mask"] is not None: nm = out["noise_mask"] - if torch.is_tensor(nm): - if nm.ndim == 3: - # [B, H, W] - out["noise_mask"] = torch.rot90(nm, k=3, dims=(1, 2)) - elif nm.ndim == 4: - # [B, 1, H, W] or [B, C, H, W] - out["noise_mask"] = torch.rot90(nm, k=3, dims=(2, 3)) - # else: ignore unusual shapes + if torch.is_tensor(nm) and nm.ndim >= 2: + out["noise_mask"] = torch.rot90(nm, k=3, dims=(-2, -1)) # Return new latent dimensions (latent units) final_samples = out["samples"] - latent_h = int(final_samples.shape[2]) - latent_w = int(final_samples.shape[3]) + latent_h = int(final_samples.shape[-2]) + latent_w = int(final_samples.shape[-1]) return (out, latent_w, latent_h) diff --git a/py/nodes/bv_pipe.py b/py/nodes/bv_pipe.py index 39ddf0f..f0b14b2 100644 --- a/py/nodes/bv_pipe.py +++ b/py/nodes/bv_pipe.py @@ -1,6 +1,6 @@ # bv_pipe.py from __future__ import annotations -from typing import Dict, Any, List, Tuple +from typing import Dict, Any, List PIPE = "BV_PIPE" @@ -44,7 +44,7 @@ class BVPipeNode: RETURN_TYPES = (PIPE,) + (ANY,) * 100 RETURN_NAMES = ("pipe",) + tuple([f"out_{i:03d}" for i in range(1, 101)]) FUNCTION = "run" - CATEGORY = "🌀 BV Node Pack/pipe" + CATEGORY = "🌀 BV Node Pack/deprecated" def run(self, pipe: Dict[str, Any], **kwargs): pipe = pipe or {} @@ -95,5 +95,5 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { - "BV Pipe": "🌀 BV Pipe", + "BV Pipe": "🌀 BV Pipe [Deprecated]", } diff --git a/py/nodes/bv_pipe_config.py b/py/nodes/bv_pipe_config.py index b62d9c7..59a6eff 100644 --- a/py/nodes/bv_pipe_config.py +++ b/py/nodes/bv_pipe_config.py @@ -26,7 +26,7 @@ class BVPipeConfigNode: RETURN_TYPES = (PIPE,) RETURN_NAMES = ("pipe",) FUNCTION = "run" - CATEGORY = "🌀 BV Node Pack/pipe" + CATEGORY = "🌀 BV Node Pack/deprecated" def run(self, names: str): # store config in the pipe object @@ -46,5 +46,5 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { - "BV Pipe Config": "🌀 BV Pipe Config", + "BV Pipe Config": "🌀 BV Pipe Config [Deprecated]", } diff --git a/py/nodes/bv_prompt_debug.py b/py/nodes/bv_prompt_debug.py index d304558..24faa39 100644 --- a/py/nodes/bv_prompt_debug.py +++ b/py/nodes/bv_prompt_debug.py @@ -14,7 +14,6 @@ class BVPromptASTDebugNode: "pretty": ("BOOLEAN", {"default": True}), "indent": ("INT", {"default": 2, "min": 0, "max": 8, "step": 1}), "sort_keys": ("BOOLEAN", {"default": False}), - "debug_output": ("STRING", {"default": "", "multiline": True}), } } @@ -24,7 +23,7 @@ class BVPromptASTDebugNode: FUNCTION = "run" CATEGORY = "🌀 BV Node Pack/prompting" - def run(self, ast, pretty=True, indent=2, sort_keys=False, debug_output=""): + def run(self, ast, pretty=True, indent=2, sort_keys=False): try: if pretty: txt = json.dumps(ast, ensure_ascii=False, indent=int(indent), sort_keys=bool(sort_keys)) @@ -33,12 +32,11 @@ class BVPromptASTDebugNode: except Exception as e: txt = f"[BV AST Debug] Failed to serialize AST to JSON: {type(e).__name__}: {e}\n\nRaw:\n{repr(ast)}" - # WICHTIG: UI + result zurückgeben return { "ui": { - "ast_json": [txt], # frei benennbar + "ast_json": [txt], }, - "result": (txt,) # das ist dein normaler Node-Output + "result": (txt,) } diff --git a/py/nodes/bv_smart_pipe.py b/py/nodes/bv_smart_pipe.py new file mode 100644 index 0000000..de43e4f --- /dev/null +++ b/py/nodes/bv_smart_pipe.py @@ -0,0 +1,111 @@ +import json + + +SMART_PIPE = "BV_SMART_PIPE" +MAX_SLOTS = 100 + + +class AnyType(str): + def __ne__(self, value): + return False + + +ANY = AnyType("*") + + +class BVSmartPipe: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "bv_smart_pipe_schema_json": ("STRING", {"default": "[]", "multiline": True}), + "bv_smart_pipe_route_json": ("STRING", {"default": "{}", "multiline": True}), + }, + "optional": { + "pipe": (SMART_PIPE,), + **{f"v_{index:03d}": (ANY,) for index in range(1, MAX_SLOTS + 1)}, + }, + } + + RETURN_TYPES = (SMART_PIPE,) + (ANY,) * MAX_SLOTS + RETURN_NAMES = ("pipe",) + tuple(f"out_{index:03d}" for index in range(1, MAX_SLOTS + 1)) + FUNCTION = "run" + CATEGORY = "🌀 BV Node Pack/pipe" + DESCRIPTION = "Carries an independently configurable, chain-growing set of values identified by stable slot IDs." + + @classmethod + def VALIDATE_INPUTS(cls, bv_smart_pipe_schema_json, bv_smart_pipe_route_json="{}", **kwargs): + try: + schema = json.loads(bv_smart_pipe_schema_json) + except (TypeError, json.JSONDecodeError) as error: + return f"BV Smart Pipe schema is invalid: {error}" + if not isinstance(schema, list) or len(schema) > MAX_SLOTS: + return f"BV Smart Pipe schema must be a list with at most {MAX_SLOTS} slots." + ids = set() + names = set() + for slot in schema: + if not isinstance(slot, dict) or not slot.get("id") or not slot.get("name"): + return "Every BV Smart Pipe slot needs an id and name." + if slot["id"] in ids: + return f"Duplicate BV Smart Pipe slot id: {slot['id']}" + if slot["name"] in names: + return f"BV Smart Pipe enthält den Slot-Namen „{slot['name']}“ mehrfach. Prüfe die Merge-Konfiguration oder benenne einen inkompatiblen Slot um." + if slot.get("missing") and slot.get("connected"): + return f"Missing BV Smart Pipe slot is still used: {slot['name']}" + ids.add(slot["id"]) + names.add(slot["name"]) + try: + route = json.loads(bv_smart_pipe_route_json) + except (TypeError, json.JSONDecodeError) as error: + return f"BV Smart Pipe routing metadata is invalid: {error}" + if not isinstance(route, dict) or not route.get("nodeId") or not route.get("name"): + return "BV Smart Pipe routing metadata needs a nodeId and name." + return True + + def run(self, bv_smart_pipe_schema_json, bv_smart_pipe_route_json="{}", pipe=None, **kwargs): + schema = json.loads(bv_smart_pipe_schema_json) + upstream_values = (pipe or {}).get("values", {}) + values = {} + outputs = [None] * MAX_SLOTS + + for slot in schema: + ordinal = int(slot["ordinal"]) + key = f"v_{ordinal:03d}" + if key in kwargs: + value = kwargs[key] + else: + value = upstream_values.get(slot["id"]) + values[slot["id"]] = value + outputs[ordinal - 1] = value + + route = json.loads(bv_smart_pipe_route_json) + upstream_writers = (pipe or {}).get("writers", {}) + upstream_provenance = (pipe or {}).get("provenance", {}) + writers = {} + provenance = {} + for slot in schema: + key = f"v_{int(slot['ordinal']):03d}" + slot_id = slot["id"] + inherited_path = list(upstream_provenance.get(slot_id) or []) + if not inherited_path and upstream_writers.get(slot_id): + inherited_path = [upstream_writers[slot_id]] + if key in kwargs: + writer = route.get("nodeId") + writers[slot_id] = writer + provenance[slot_id] = [*inherited_path, writer] if writer else inherited_path + else: + writers[slot_id] = upstream_writers.get(slot_id) + provenance[slot_id] = inherited_path + out_pipe = { + "version": 2, + "schema": schema, + "values": values, + "writers": writers, + "provenance": provenance, + } + return (out_pipe, *outputs) + + +NODE_CLASS_MAPPINGS = {"BV Smart Pipe": BVSmartPipe} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Smart Pipe": "🌀 BV Smart Pipe"} diff --git a/py/nodes/bv_smart_pipe_merge.py b/py/nodes/bv_smart_pipe_merge.py new file mode 100644 index 0000000..d529fc5 --- /dev/null +++ b/py/nodes/bv_smart_pipe_merge.py @@ -0,0 +1,157 @@ +import json + +from .bv_smart_pipe import SMART_PIPE + + +MAX_SOURCES = 16 + + +def _unique_name(base_name, used_names): + if base_name not in used_names: + return base_name + suffix = 2 + while f"{base_name}_{suffix}" in used_names: + suffix += 1 + return f"{base_name}_{suffix}" + + +def _provenance_for(pipe, slot_id): + path = list(pipe.get("provenance", {}).get(slot_id) or []) + writer = pipe.get("writers", {}).get(slot_id) + return path or ([writer] if writer else []) + + +def _is_strict_prefix(prefix, path): + return len(prefix) < len(path) and path[:len(prefix)] == prefix + + +def _normalized_name(slot): + return str(slot.get("name") or "").strip().casefold() + + +def _compatible_types(left, right): + return not left or not right or left == "*" or right == "*" or left == right + + +def merge_smart_pipes(pipes): + schema_by_id = {} + slot_order = [] + values = {} + writers = {} + provenance = {} + aliases = {} + + for pipe in pipes: + if not pipe: + continue + pipe_schema = pipe.get("schema", []) + pipe_values = pipe.get("values", {}) + pipe_writers = pipe.get("writers", {}) + for slot in pipe_schema: + slot_id = slot.get("id") + if not slot_id: + continue + canonical_id = aliases.get(slot_id, slot_id) + if canonical_id not in schema_by_id: + matching_id = next((candidate_id for candidate_id in slot_order + if _normalized_name(schema_by_id[candidate_id]) == _normalized_name(slot) + and _compatible_types(schema_by_id[candidate_id].get("type"), slot.get("type"))), None) + canonical_id = matching_id or slot_id + aliases[slot_id] = canonical_id + if canonical_id not in schema_by_id: + schema_by_id[canonical_id] = {**slot, "id": canonical_id} + slot_order.append(canonical_id) + elif schema_by_id[canonical_id].get("type") == "*" and slot.get("type") not in (None, "*"): + schema_by_id[canonical_id]["type"] = slot["type"] + if slot_id in pipe_values: + incoming_path = _provenance_for(pipe, slot_id) + current_path = provenance.get(canonical_id, []) + # A later branch that only inherited an ancestor value must not + # erase a genuine downstream write from another branch. For + # divergent writes, the configured source order still wins. + if canonical_id in values and _is_strict_prefix(incoming_path, current_path): + continue + values[canonical_id] = pipe_values[slot_id] + provenance[canonical_id] = incoming_path + if slot_id in pipe_writers: + writers[canonical_id] = pipe_writers[slot_id] + + used_names = set() + schema = [] + for ordinal, slot_id in enumerate(slot_order, start=1): + slot = schema_by_id[slot_id] + name = _unique_name(str(slot.get("name") or "slot"), used_names) + used_names.add(name) + schema.append({ + **slot, + "id": slot_id, + "name": name, + "ordinal": ordinal, + "missing": False, + "dormant": False, + }) + + return { + "version": 2, + "schema": schema, + "values": values, + "writers": writers, + "provenance": provenance, + "aliases": {slot_id: canonical_id for slot_id, canonical_id in aliases.items() if slot_id != canonical_id}, + } + + +class BVSmartPipeMerge: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "bv_smart_pipe_merge_json": ("STRING", {"default": "[]", "multiline": True}), + }, + "optional": { + **{f"pipe_{index:03d}": (SMART_PIPE,) for index in range(1, MAX_SOURCES + 1)}, + }, + } + + RETURN_TYPES = (SMART_PIPE,) + RETURN_NAMES = ("pipe",) + FUNCTION = "run" + CATEGORY = "🌀 BV Node Pack/pipe" + DESCRIPTION = "Merges ordered wired and wireless Smart Pipe branches. Genuine branch writes beat inherited base values; later divergent writes win." + + @classmethod + def VALIDATE_INPUTS(cls, bv_smart_pipe_merge_json, **kwargs): + try: + sources = json.loads(bv_smart_pipe_merge_json) + except (TypeError, json.JSONDecodeError) as error: + return f"BV Smart Pipe Merge configuration is invalid: {error}" + if not isinstance(sources, list) or len(sources) > MAX_SOURCES: + return f"BV Smart Pipe Merge needs a list with at most {MAX_SOURCES} sources." + keys = [] + addresses = [] + for source in sources: + key = source.get("key") if isinstance(source, dict) else None + if key not in {f"pipe_{index:03d}" for index in range(1, MAX_SOURCES + 1)}: + return f"Invalid BV Smart Pipe Merge source: {key}" + keys.append(key) + address = source.get("address") + if address: + addresses.append(address) + if len(keys) != len(set(keys)): + return "BV Smart Pipe Merge source keys must be unique." + if len(addresses) != len(set(addresses)): + return "BV Smart Pipe Merge cannot use the same source more than once." + return True + + def run(self, bv_smart_pipe_merge_json, **kwargs): + sources = json.loads(bv_smart_pipe_merge_json) + missing = [source["key"] for source in sources if kwargs.get(source["key"]) is None] + if missing: + raise ValueError(f"BV Smart Pipe Merge sources are missing: {', '.join(missing)}") + ordered_pipes = [kwargs[source["key"]] for source in sources] + return (merge_smart_pipes(ordered_pipes),) + + +NODE_CLASS_MAPPINGS = {"BV Smart Pipe Merge": BVSmartPipeMerge} + +NODE_DISPLAY_NAME_MAPPINGS = {"BV Smart Pipe Merge": "🌀 BV Smart Pipe Merge"} diff --git a/py/nodes/bv_subgraph_dynamic_combo.py b/py/nodes/bv_subgraph_dynamic_combo.py index 3fe38f8..a4b683e 100644 --- a/py/nodes/bv_subgraph_dynamic_combo.py +++ b/py/nodes/bv_subgraph_dynamic_combo.py @@ -1,3 +1,6 @@ +import math + + def _contains_link(obj, my_id: str, out_index: int) -> bool: # Link format typically: [node_id, output_index] if isinstance(obj, list): @@ -40,6 +43,13 @@ class BVDynamicCombo: def run(self, value: str, options: str, prompt=None, unique_id=None): s = (value or "").strip() + parsed_options = [line.strip() for line in (options or "").splitlines() if line.strip()] + if not parsed_options: + raise Exception("BV Dynamic Combo: at least one option is required.") + if len(parsed_options) != len(set(parsed_options)): + raise Exception("BV Dynamic Combo: duplicate options are not allowed.") + if s not in parsed_options: + raise Exception(f"BV Dynamic Combo: selected value '{s}' is not present in options.") needs_int = is_output_used(prompt, unique_id, 1) needs_float = is_output_used(prompt, unique_id, 2) @@ -58,8 +68,10 @@ class BVDynamicCombo: if needs_float: try: f = float(s) + if not math.isfinite(f): + raise ValueError except Exception: - raise Exception(f"BV Dynamic Combo: FLOAT output is connected, but value '{s}' is not a valid float.") + raise Exception(f"BV Dynamic Combo: FLOAT output is connected, but value '{s}' is not a finite float.") return (s, i, f) diff --git a/py/nodes/bv_subgraph_heading.py b/py/nodes/bv_subgraph_heading.py index 1a0263c..d29cbc3 100644 --- a/py/nodes/bv_subgraph_heading.py +++ b/py/nodes/bv_subgraph_heading.py @@ -19,7 +19,7 @@ class BVSubgraphHeading: FUNCTION = "run" CATEGORY = "🌀 BV Node Pack/subgraph" - def run(self, value): + def run(self, value, font_size, show_divider): return (value,) diff --git a/py/nodes/bv_subgraph_spacer.py b/py/nodes/bv_subgraph_spacer.py index 6048c09..ace52c1 100644 --- a/py/nodes/bv_subgraph_spacer.py +++ b/py/nodes/bv_subgraph_spacer.py @@ -13,7 +13,7 @@ class BVSubgraphSpacer: FUNCTION = "run" CATEGORY = "🌀 BV Node Pack/subgraph" - def run(self, height): + def run(self, height, scale): return (height,) diff --git a/py/nodes/bv_util_hex_color_int.py b/py/nodes/bv_util_hex_color_int.py index 8b63ecb..078ae8b 100644 --- a/py/nodes/bv_util_hex_color_int.py +++ b/py/nodes/bv_util_hex_color_int.py @@ -19,15 +19,20 @@ class BVHexColorToInt: CATEGORY = "🌀 BV Node Pack/utils" def run(self, hex: str): - s = hex.strip().lower() + color = hex + s = color.strip().lower() # Accept: "#rrggbb", "0xrrggbb", "rrggbb" s = s.replace("0x", "") if s.startswith("#"): s = s[1:] - if not re.fullmatch(r"[0-9a-f]{6}", s): - raise ValueError(f"Invalid hex color: '{hex}'. Expected #RRGGBB / 0xRRGGBB / RRGGBB.") + if re.fullmatch(r"[0-9a-f]{3}", s): + s = "".join(ch * 2 for ch in s) + elif not re.fullmatch(r"[0-9a-f]{6}", s): + raise ValueError( + f"Invalid hex color: '{color}'. Expected #RGB, RGB, #RRGGBB, 0xRRGGBB, or RRGGBB." + ) value = int(s, 16) return (value,) diff --git a/py/util/latent_dimensions.py b/py/util/latent_dimensions.py new file mode 100644 index 0000000..9db1fcb --- /dev/null +++ b/py/util/latent_dimensions.py @@ -0,0 +1,54 @@ +import math +from math import gcd + + +def parse_ratios(value: str): + ratios = [] + seen = set() + for line_number, line in enumerate(value.splitlines(), 1): + text = line.strip() + if not text: + continue + parts = text.split(":") + if len(parts) != 2: + raise ValueError(f"Invalid aspect ratio on line {line_number}: expected W:H.") + try: + width, height = (int(part.strip()) for part in parts) + except ValueError as error: + raise ValueError(f"Invalid aspect ratio on line {line_number}: W and H must be integers.") from error + if width <= 0 or height <= 0: + raise ValueError(f"Invalid aspect ratio on line {line_number}: W and H must be positive.") + divisor = gcd(width, height) + ratio = (width // divisor, height // divisor) + if ratio in seen: + raise ValueError(f"Duplicate aspect ratio on line {line_number}: {ratio[0]}:{ratio[1]}.") + seen.add(ratio) + ratios.append(ratio) + if not ratios: + raise ValueError("At least one aspect ratio is required.") + return ratios + + +def combine_ratios(standard_ratios, custom_value: str): + ratios = list(standard_ratios) + seen = set(ratios) + if custom_value.strip(): + for ratio in parse_ratios(custom_value): + if ratio not in seen: + seen.add(ratio) + ratios.append(ratio) + if not ratios: + raise ValueError("At least one aspect ratio must be enabled or entered.") + return ratios + + +def dimensions_for_area(square_resolution: int, ratio, alignment: int): + ratio_width, ratio_height = ratio + area = square_resolution * square_resolution + width = math.sqrt(area * ratio_width / ratio_height) + height = math.sqrt(area * ratio_height / ratio_width) + + def align(value): + return max(alignment, int(round(value / alignment)) * alignment) + + return align(width), align(height) diff --git a/py/util/prompt/category.py b/py/util/prompt/category.py index 02e7d8c..c5fa97a 100644 --- a/py/util/prompt/category.py +++ b/py/util/prompt/category.py @@ -42,6 +42,7 @@ def parse_prompt_to_ast( # Stack for nested spans (span nodes) span_stack: List[ASTNode] = [] + span_locations: List[tuple[str, int, int]] = [] def current_children() -> List[ASTNode]: return span_stack[-1]["children"] if span_stack else current_block["children"] @@ -60,25 +61,37 @@ def parse_prompt_to_ast( return current_children().append(make_node("comment", v=s)) - def start_span(cat: str) -> None: + def start_span(cat: str, line_number: int, column: int) -> None: span = make_node("span", cat=cat, children=[]) current_children().append(span) span_stack.append(span) + span_locations.append((cat, line_number, column)) def end_span() -> None: if span_stack: span_stack.pop() + span_locations.pop() + + def split_line_ending(s: str) -> tuple[str, str]: + for ending in ("\r\n", "\n", "\r"): + if s.endswith(ending): + return s[:-len(ending)], ending + return s, "" # Process line-by-line to support block directives - for line in text.splitlines(keepends=True): + for line_number, line in enumerate(text.splitlines(keepends=True), start=1): # --- Block directive handling (standalone line starting with @@) --- if line.startswith("@@"): # Only treat as directive if the entire line is "@@\n" (or "@@\n") raw = line[2:] candidate = raw.strip("\r\n").strip() if candidate == "" or is_valid_cat(candidate): - # Close any open spans at a block boundary (fail-soft) - span_stack.clear() + if span_stack: + cat, start_line, start_column = span_locations[-1] + raise ValueError( + f"Unclosed inline category '{cat}' opened at line {start_line}, " + f"column {start_column} before block directive on line {line_number}." + ) cat = candidate or "default" current_block = make_node("block", cat=cat, children=[]) root["children"].append(current_block) @@ -91,7 +104,9 @@ def parse_prompt_to_ast( # Inline or line comment detection (outside of special structures) # Treat everything from comment_prefix to end-of-line as a comment node. if line.startswith(comment_prefix, i): - emit_comment(line[i:]) # includes newline (keepends=True) + comment, ending = split_line_ending(line[i:]) + emit_comment(comment) + emit_text(ending) break # Inline span end: @@ (pop one span) @@ -100,6 +115,11 @@ def parse_prompt_to_ast( i += 2 continue + if line.startswith("@@", i): + raise ValueError( + f"Unexpected inline category closer '@@' at line {line_number}, column {i + 1}." + ) + # Inline span start: @ if line.startswith("@<", i): j = i + 2 @@ -111,7 +131,7 @@ def parse_prompt_to_ast( if j < len(line) and line[j] == ">": cat = "".join(cat_chars).strip() if is_valid_cat(cat): - start_span(cat) + start_span(cat, line_number, i + 1) i = j + 1 # Swallow exactly one whitespace after the '>' if i < len(line) and line[i] in (" ", "\t"): @@ -123,6 +143,12 @@ def parse_prompt_to_ast( emit_text(line[i]) i += 1 + if span_stack: + cat, line_number, column = span_locations[-1] + raise ValueError( + f"Unclosed inline category '{cat}' opened at line {line_number}, column {column}." + ) + return root diff --git a/pyproject.toml b/pyproject.toml index cf395ce..15b54e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "bv_nodepack" -version = "0.0.24" -description = "A ComfyUI node pack providing advanced utility and workflow nodes, including a config-driven Control Center for muting and bypassing node groups, a flexible Pipe system, a AST (Abstract Syntax Tree) prompting system , and Subgraph-friendly UI nodes (Heading, Spacer, Divider) with dynamic JavaScript rendering." +version = "0.1.0" +description = "Advanced ComfyUI workflow toolkit featuring wireless, branchable Smart Pipes with ordered merges and Subgraph routing; user-defined group state controls; stable composable Subgraph UIs; structured AST prompt processing; and flexible latent utilities." authors = [ {name = "BlackVortex", email = "blackvortexx@proton.me"} ] diff --git a/scripts/embed_example_workflows.py b/scripts/embed_example_workflows.py new file mode 100644 index 0000000..15af729 --- /dev/null +++ b/scripts/embed_example_workflows.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from PIL import Image, PngImagePlugin + + +ROOT = Path(__file__).resolve().parents[1] +IMAGES = ROOT / "examples" / "images" +WORKFLOWS = ROOT / "examples" / "workflows" + +EXAMPLES = { + "prompt-ast-categories": ((40, 160, 1240, 550), "_ast-full.png"), + "empty-latent-random-ratio": (None, None), + "subgraph-ui-layout": (None, None), + "smart-pipe-wireless-merge": ((40, 160, 1240, 500), "_smart-full.png"), + "control-center-workflow-states": ((150, 150, 1120, 610), "_control-full.png"), +} + +CONFIG_SCREENSHOTS = { + "smart-pipe-slot-config.png": (180, 175, 1100, 550), + "smart-pipe-merge-config.png": (250, 195, 1030, 525), + "control-center-config.png": (180, 80, 1110, 640), +} + + +for stem, (crop, source_name) in EXAMPLES.items(): + workflow_path = WORKFLOWS / f"{stem}.json" + source_path = IMAGES / (source_name or f"{stem}.png") + target_path = IMAGES / f"{stem}.png" + workflow = json.loads(workflow_path.read_text(encoding="utf-8")) + with Image.open(source_path) as source: + image = source.crop(crop) if crop else source.copy() + metadata = PngImagePlugin.PngInfo() + metadata.add_text("workflow", json.dumps(workflow, separators=(",", ":"), ensure_ascii=False)) + image.save(target_path, pnginfo=metadata, optimize=True) + +for name, crop in CONFIG_SCREENSHOTS.items(): + path = IMAGES / name + with Image.open(path) as source: + source.crop(crop).save(path, optimize=True) + +for temporary in (IMAGES / "_smart-full.png", IMAGES / "_control-full.png", IMAGES / "_ast-full.png"): + temporary.unlink(missing_ok=True) diff --git a/scripts/generate_example_workflows.mjs b/scripts/generate_example_workflows.mjs new file mode 100644 index 0000000..535c0e5 --- /dev/null +++ b/scripts/generate_example_workflows.mjs @@ -0,0 +1,165 @@ +import fs from "node:fs"; +import path from "node:path"; + +const root = path.resolve(import.meta.dirname, ".."); +const out = path.join(root, "examples", "workflows"); +fs.mkdirSync(out, { recursive: true }); + +const base = (nodes, links = [], groups = [], extra = {}) => ({ + id: crypto.randomUUID(), revision: 0, + last_node_id: Math.max(...nodes.map((node) => Number(node.id)), 0), + last_link_id: Math.max(...links.map((link) => Number(link[0])), 0), + nodes, links, groups, config: {}, + extra: { ds: { scale: 1, offset: [80, 80] }, frontendVersion: "1.48.7", ...extra }, + version: 0.4, +}); +const note = (id, title, text, pos, size = [380, 180]) => ({ + id, type: "MarkdownNote", pos, size, flags: {}, order: id, mode: 0, + inputs: [], outputs: [], title, properties: {}, widgets_values: [text], color: "#223", bgcolor: "#112", +}); +const props = (name) => ({ "Node name for S&R": name, cnr_id: "bv_nodepack", ver: "0.1.0" }); +const input = (name, type, link = null, widget = false, label = undefined) => ({ name, type, link, ...(widget ? { widget: { name } } : {}), ...(label ? { label } : {}) }); +const output = (name, type, links = null) => ({ name, type, links }); +const save = (name, workflow) => fs.writeFileSync(path.join(out, name), `${JSON.stringify(workflow, null, 2)}\n`); +const showText = (id, pos, link, value = "") => ({ + id, type: "ShowText|pysssss", pos, size: [260, 90], flags: {}, order: id, mode: 0, + inputs: [{ localized_name: "text", name: "text", type: "STRING", link }], + outputs: [{ localized_name: "STRING", name: "STRING", shape: 6, type: "STRING", links: null }], + properties: { cnr_id: "comfyui-custom-scripts", ver: "1.2.5", "Node name for S&R": "ShowText|pysssss" }, + widgets_values: [value], +}); + +// Prompt AST: a real category workflow with two independent consumers. +save("prompt-ast-categories.json", base([ + note(1, "Prompt AST – categories instead of string surgery", "## Practical use\nMaintain one structured prompt, enable or disable optional blocks, then decode only the categories required by a downstream encoder. The Debug node keeps the AST visible while building the workflow.", [20, 20], [430, 210]), + { id: 2, type: "BV Prompt Encode", pos: [500, 40], size: [360, 240], flags: {}, order: 2, mode: 0, + inputs: [input("prompt", "STRING", null, true)], outputs: [output("ast", "BV_AST", [1, 2]), output("cleaned_prompt", "STRING")], properties: props("BV Prompt Encode"), + widgets_values: ["A cinematic portrait of an astronaut\n@@lighting\nsoft rim light, volumetric haze\n@@detail\nhighly detailed helmet reflections\n@@negative\ntext, watermark"] }, + { id: 3, type: "BV Prompt Category Switch", pos: [930, 40], size: [340, 190], flags: {}, order: 3, mode: 0, + inputs: [input("ast", "BV_AST", 1), input("enable_categories", "STRING", null, true), input("disable_categories", "STRING", null, true), input("inherit", "BOOLEAN", null, true), input("mode", "BOOLEAN", null, true)], + outputs: [output("ast", "BV_AST", [3]), output("report", "STRING")], properties: props("BV Prompt Category Switch"), widgets_values: ["lighting\ndetail", "negative", true, true] }, + { id: 4, type: "BV Prompt Decode", pos: [1340, 40], size: [340, 220], flags: {}, order: 4, mode: 0, + inputs: [input("ast", "BV_AST", 3), input("category_filter", "STRING", null, true), input("with_comments", "BOOLEAN", null, true), input("inherit", "BOOLEAN", null, true), input("mode", "BOOLEAN", null, true), input("prettify", "BOOLEAN", null, true)], + outputs: [output("ast", "BV_AST"), output("filtered_prompt", "STRING", [4])], properties: props("BV Prompt Decode"), widgets_values: ["default, lighting, detail", false, true, true, true] }, + { id: 5, type: "BV Prompt AST Debug", pos: [930, 300], size: [750, 230], flags: {}, order: 5, mode: 0, + inputs: [input("ast", "BV_AST", 2), input("pretty", "BOOLEAN", null, true), input("indent", "INT", null, true), input("sort_keys", "BOOLEAN", null, true)], + outputs: [output("ast_json", "STRING")], properties: props("BV Prompt AST Debug"), widgets_values: [true, 2, false] }, + showText(6, [1740, 80], 4, "A cinematic portrait of an astronaut\nsoft rim light, volumetric haze\nhighly detailed helmet reflections"), +], [[1,2,0,3,0,"BV_AST"],[2,2,0,5,0,"BV_AST"],[3,3,0,4,0,"BV_AST"],[4,4,1,6,0,"STRING"]])); + +// Random latent: configured for a realistic portrait workflow. +save("empty-latent-random-ratio.json", base([ + note(1, "Random ratio latent – reproducible composition variants", "## Practical use\nGenerate a deterministic mix of landscape, portrait and square canvases for prompt exploration. Standard ratios are one-click toggles; project-specific ratios stay in the text field. Connect **latent** directly to your sampler.", [20, 20], [430, 220]), + { id: 2, type: "BV Empty Latent Random Ratio", pos: [500, 20], size: [420, 430], flags: {}, order: 2, mode: 0, + inputs: [input("seed","INT",null,true),input("resolution","COMBO",null,true),input("custom_resolution","INT",null,true),input("alignment","COMBO",null,true), + ...["use_1_1","use_3_2","use_2_3","use_4_3","use_3_4","use_16_9","use_9_16","use_21_9","use_9_21"].map((name)=>input(name,"BOOLEAN",null,true)),input("ratios","STRING",null,true),input("batch_size","INT",null,true)], + outputs: [output("latent","LATENT"),output("width","INT"),output("height","INT"),output("picked_ratio","STRING")], properties: props("BV Empty Latent Random Ratio"), + widgets_values: [42,"1024",1024,"64",true,true,true,false,false,true,true,false,false,"5:4\n4:5",1] }, + note(3, "Suggested downstream wiring", "**latent → KSampler.latent_image**\n\nThe additional width, height and picked_ratio outputs are useful for filenames, metadata and resolution-aware post-processing.", [980, 80], [360, 190]), +])); + +// Subgraph UI building blocks, laid out in the same order they should be exposed. +save("subgraph-ui-layout.json", base([ + note(1, "Build a clean Subgraph interface", "## Practical use\nExpose these inputs in the shown order. The outer Subgraph node becomes a small purpose-built UI: heading, model preset, divider, numeric settings and intentional spacing.", [20, 20], [420, 210]), + { id: 2, type: "BV Subgraph Heading", pos: [500, 20], size: [330, 150], flags: {}, order: 2, mode: 0, + inputs: [input("value","STRING",null,true),input("font_size","INT",null,true),input("show_divider","BOOLEAN",null,true)], outputs:[output("_","STRING")], properties:props("BV Subgraph Heading"), widgets_values:["Generation Settings",26,false] }, + { id: 3, type: "BV Dynamic Combo", pos: [500, 205], size: [330, 210], flags: {}, order: 3, mode: 0, + inputs:[input("value","STRING",null,true),input("options","STRING",null,true)], outputs:[output("value_str","STRING"),output("value_int","INT"),output("value_float","FLOAT")], properties:props("BV Dynamic Combo"), widgets_values:["Balanced","Fast\nBalanced\nQuality"] }, + { id: 4, type: "BV Subgraph Divider", pos: [900, 70], size: [300, 145], flags: {}, order: 4, mode: 0, + inputs:[input("thickness","INT",null,true),input("padding","INT",null,true),input("alpha","FLOAT",null,true)], outputs:[output("_","STRING")], properties:props("BV Subgraph Divider"), widgets_values:[2,10,0.35] }, + { id: 5, type: "BV Subgraph Spacer", pos: [900, 250], size: [300, 125], flags: {}, order: 5, mode: 0, + inputs:[input("height","INT",null,true),input("scale","FLOAT",null,true)], outputs:[output("_","STRING")], properties:props("BV Subgraph Spacer"), widgets_values:[24,1] }, + note(6, "Expose to the Subgraph input", "Connect each presentation output to the Subgraph input bracket, then expose the functional values between them. Stable identities preserve this order across save/load and input changes.", [1260, 90], [380, 210]), +])); + +// Use the validated live test graphs for the complex stateful examples. +for (const [source, target] of [ + ["Dev Pipe Test.json", "smart-pipe-wireless-merge.json"], + ["BV Control Center Test.json", "control-center-workflow-states.json"], +]) { + const sourcePath = path.resolve(root, "..", "..", "user", "default", "workflows", source); + if (!fs.existsSync(sourcePath)) continue; + const workflow = JSON.parse(fs.readFileSync(sourcePath, "utf8")); + if (target === "smart-pipe-wireless-merge.json") { + const positions = new Map([[204,[20,30]],[199,[320,20]],[205,[20,310]],[198,[320,300]],[200,[820,170]],[201,[1260,150]]]); + workflow.nodes = workflow.nodes.filter((node) => positions.has(node.id)); + for (const node of workflow.nodes) node.pos = positions.get(node.id); + const ids = new Set(workflow.nodes.map((node) => node.id)); + workflow.links = workflow.links.filter((link) => ids.has(link[1]) && ids.has(link[3])); + const first = workflow.nodes.find((node) => node.id === 199); + const second = workflow.nodes.find((node) => node.id === 198); + const afterMerge = workflow.nodes.find((node) => node.id === 201); + workflow.nodes.find((node) => node.id === 204).widgets_values = ["A lighthouse above a stormy sea"]; + workflow.nodes.find((node) => node.id === 205).widgets_values = ["text, watermark"]; + const firstSlot = first.properties.bvSmartPipe.localSlots[0]; + const secondSlot = second.properties.bvSmartPipe.localSlots[0]; + Object.assign(firstSlot, { name: "prompt" }); + Object.assign(first.properties.bvSmartPipe.resolvedSlots[0], { name: "prompt" }); + Object.assign(first.inputs[1], { label: "prompt", localized_name: "prompt" }); + Object.assign(secondSlot, { name: "negative_prompt" }); + Object.assign(second.properties.bvSmartPipe.resolvedSlots[0], { name: "negative_prompt" }); + Object.assign(second.inputs[1], { label: "negative_prompt", localized_name: "negative_prompt" }); + + const styleSlot = { id: "5c881b13-e3ab-4caf-9a09-92659fba6419", name: "style", ordinal: 2, type: "STRING", showInput: true, showOutput: false }; + first.properties.bvSmartPipe.localSlots.push(styleSlot); + first.properties.bvSmartPipe.resolvedSlots.push({ ...styleSlot, missing: false, dormant: false }); + first.properties.bvSmartPipe.nextOrdinal = 3; + first.inputs.splice(2, 0, { label: "style", localized_name: "style", name: "v_002", shape: 7, type: "STRING", link: 188 }); + first.widgets_values[0] = JSON.stringify([ + { ...first.properties.bvSmartPipe.resolvedSlots[0], connected: true }, + { ...first.properties.bvSmartPipe.resolvedSlots[1], connected: true }, + ]); + second.widgets_values[0] = JSON.stringify([{ ...second.properties.bvSmartPipe.resolvedSlots[0], connected: true }]); + + const mergedSlots = [ + { ...first.properties.bvSmartPipe.resolvedSlots[0], showInput: false, showOutput: true }, + { ...first.properties.bvSmartPipe.resolvedSlots[1], showInput: false, showOutput: true }, + { ...second.properties.bvSmartPipe.resolvedSlots[0], showInput: false, showOutput: true }, + ]; + afterMerge.properties.bvSmartPipe.inheritedSlots = mergedSlots.map((slot) => ({ ...slot })); + afterMerge.properties.bvSmartPipe.resolvedSlots = mergedSlots.map((slot) => ({ ...slot })); + afterMerge.outputs = [afterMerge.outputs[0], ...mergedSlots.map((slot, index) => ({ localized_name: slot.name, name: slot.name, type: "STRING", links: [189 + index] }))]; + afterMerge.widgets_values[0] = JSON.stringify(mergedSlots.map((slot) => ({ ...slot, connected: true }))); + const routeKey = Object.keys(workflow.extra.bvSmartPipeRoutes).find((key) => key.endsWith(afterMerge.properties.bvSmartPipe.routing.nodeId)); + workflow.extra.bvSmartPipeRoutes[routeKey].projection = { version: 1, inheritedSlots: mergedSlots.map((slot) => ({ ...slot })), resolvedSlots: mergedSlots.map((slot) => ({ ...slot })) }; + + const styleValue = JSON.parse(JSON.stringify(workflow.nodes.find((node) => node.id === 204))); + Object.assign(styleValue, { id: 206, pos: [20, 150], order: 11, widgets_values: ["cinematic"] }); + styleValue.outputs[0].links = [188]; + workflow.nodes.push(styleValue, showText(207, [1740, 40], 189, "A lighthouse above a stormy sea"), showText(208, [1740, 170], 190, "cinematic"), showText(209, [1740, 300], 191, "text, watermark")); + workflow.links.push([188,206,0,199,2,"STRING"],[189,201,1,207,0,"STRING"],[190,201,2,208,0,"STRING"],[191,201,3,209,0,"STRING"]); + workflow.last_node_id = 209; workflow.last_link_id = 191; + workflow.extra.ds = { scale: 0.72, offset: [100, 250] }; + } + if (target === "control-center-workflow-states.json") { + const keep = new Set([6, 2, 3]); + workflow.nodes = workflow.nodes.filter((node) => keep.has(node.id)); + const control = workflow.nodes.find((node) => node.id === 6); + const generate = workflow.nodes.find((node) => node.id === 2); + const upscale = workflow.nodes.find((node) => node.id === 3); + control.pos = [30, 150]; control.size = [330, 130]; control.title = "🌀 BV Control Center"; + const controlConfig = JSON.parse(control.widgets_values[0]); + controlConfig.controls[0].name = "Generate only"; + controlConfig.controls[0].assignments = controlConfig.controls[0].assignments.filter((assignment) => assignment.groupTitle === "Upscale"); + controlConfig.controls[1].name = "Full pipeline"; + controlConfig.controls[1].assignments = controlConfig.controls[1].assignments.map((assignment) => ({ ...assignment, groupId: "db2a373c-9542-4657-ad6d-1a2dd6b3757c", groupPath: "Generate", groupTitle: "Generate", action: "activate" })); + controlConfig.controls[0].enabled = true; + controlConfig.controls[1].enabled = false; + control.widgets_values[0] = JSON.stringify(controlConfig); + control.widgets_values[2] = true; + control.widgets_values[3] = false; + control.inputs[1].label = "Generate only"; + control.inputs[2].label = "Full pipeline"; + workflow.extra.bv_settings.controlConfig = controlConfig; + generate.pos = [540, 80]; generate.size = [270, 106]; generate.mode = 0; + upscale.pos = [540, 310]; upscale.size = [270, 106]; upscale.mode = 4; + workflow.groups = workflow.groups.filter((group) => group.id === 2 || group.id === 4); + const generateGroup = workflow.groups.find((group) => group.id === 4); + const upscaleGroup = workflow.groups.find((group) => group.id === 2); + generateGroup.bounding = [500, 20, 350, 205]; + upscaleGroup.bounding = [500, 250, 350, 205]; + workflow.last_node_id = 6; workflow.last_link_id = 0; + workflow.extra.ds = { scale: 0.9, offset: [140, 170] }; + } + fs.writeFileSync(path.join(out, target), `${JSON.stringify(workflow, null, 2)}\n`); +} diff --git a/tests/control_center_model.test.mjs b/tests/control_center_model.test.mjs new file mode 100644 index 0000000..77b5934 --- /dev/null +++ b/tests/control_center_model.test.mjs @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { findActiveControlConflicts, formatControlConflictStatus, nodesInControlGroup } from "../ui/src/util/control/controlCenterModel.js"; + +test("empty ComfyUI group membership falls back to group geometry after reload", () => { + const target = { id: 1, pos: [120, 120], size: [80, 40] }; + const outside = { id: 2, pos: [400, 400], size: [80, 40] }; + const item = { + group: { nodes: [], pos: [100, 100], size: [200, 120] }, + graph: { _nodes: [target, outside] }, + }; + + assert.deepEqual(nodesInControlGroup(item), [target]); +}); + +test("resolved native group membership remains authoritative", () => { + const native = { id: 1, pos: [400, 400], size: [80, 40] }; + const geometric = { id: 2, pos: [120, 120], size: [80, 40] }; + const item = { + group: { nodes: [native], pos: [100, 100], size: [200, 120] }, + graph: { _nodes: [native, geometric] }, + }; + + assert.deepEqual(nodesInControlGroup(item), [native]); +}); + +test("explicit activate wins conflicts between active controls", () => { + const conflicts = findActiveControlConflicts({ controls: [ + { id: "generate", name: "Generate only", enabled: true, assignments: [{ groupId: "upscale", groupPath: "Upscale", action: "bypass" }] }, + { id: "upscale", name: "Upscale only", enabled: true, assignments: [{ groupId: "upscale", groupPath: "Upscale", action: "activate" }] }, + ] }); + + assert.equal(conflicts.length, 1); + assert.equal(conflicts[0].winnerAction, "activate"); +}); + +test("inactive controls and identical actions do not create conflicts", () => { + const conflicts = findActiveControlConflicts({ controls: [ + { id: "a", enabled: true, assignments: [{ groupId: "g", action: "mute" }] }, + { id: "b", enabled: false, assignments: [{ groupId: "g", action: "activate" }] }, + { id: "c", enabled: true, assignments: [{ groupId: "g", action: "mute" }] }, + ] }); + + assert.deepEqual(conflicts, []); +}); + +test("node status describes conflicts and reserves a stable success row", () => { + const conflicts = findActiveControlConflicts({ controls: [ + { id: "a", name: "A", enabled: true, assignments: [{ groupId: "g", groupPath: "Upscale", action: "bypass" }] }, + { id: "b", name: "B", enabled: true, assignments: [{ groupId: "g", groupPath: "Upscale", action: "activate" }] }, + ] }); + + assert.equal(formatControlConflictStatus(conflicts), "⚠ Upscale: Activate overrides Bypass"); + assert.equal(formatControlConflictStatus([]), "✓ No active conflicts"); +}); diff --git a/tests/dynamic_combo_model.test.mjs b/tests/dynamic_combo_model.test.mjs new file mode 100644 index 0000000..eb6f859 --- /dev/null +++ b/tests/dynamic_combo_model.test.mjs @@ -0,0 +1,16 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeDynamicComboSelection, parseDynamicComboOptions } from "../js/bv_dynamic_combo_model.js"; + +test("dynamic combo options are trimmed and empty lines ignored", () => { + assert.deepEqual(parseDynamicComboOptions(" low \n\nmedium\r\n high "), ["low", "medium", "high"]); +}); + +test("removed selection falls back to the first available option", () => { + assert.equal(normalizeDynamicComboSelection("option_a", ["low", "medium", "high"]), "low"); + assert.equal(normalizeDynamicComboSelection("medium", ["low", "medium", "high"]), "medium"); +}); + +test("empty option lists keep the current value for backend validation", () => { + assert.equal(normalizeDynamicComboSelection("medium", []), "medium"); +}); diff --git a/tests/smart_pipe_merge_model.test.mjs b/tests/smart_pipe_merge_model.test.mjs new file mode 100644 index 0000000..acd498f --- /dev/null +++ b/tests/smart_pipe_merge_model.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + SMART_PIPE_DEFAULT_TITLE, + SMART_PIPE_MERGE_DEFAULT_TITLE, + compactMergeNodeHeight, + duplicateDynamicInputIndexes, + hasMergeSource, + mergePipeSchemas, + normalizeMergeSources, +} from "../js/bv_smart_pipe_merge_model.js"; + +test("new nodes retain their complete public display titles", () => { + assert.equal(SMART_PIPE_DEFAULT_TITLE, "🌀 BV Smart Pipe"); + assert.equal(SMART_PIPE_MERGE_DEFAULT_TITLE, "🌀 BV Smart Pipe Merge"); +}); + +test("the same pipe cannot be added twice across wired and wireless modes", () => { + const sources = [{ key: "pipe_001", mode: "wired", address: "root/source" }]; + assert.equal(hasMergeSource(sources, { mode: "wireless", address: "root/source" }), true); + assert.equal(hasMergeSource(sources, { mode: "wireless", address: "root/other" }), false); +}); + +test("merged frontend schema contains every source and later metadata wins", () => { + const schema = mergePipeSchemas([ + [{ id: "model", name: "model", type: "*", ordinal: 4 }], + [{ id: "model", name: "model", type: "MODEL", ordinal: 2 }, { id: "image", name: "image", type: "IMAGE", ordinal: 3 }], + ]); + assert.deepEqual(schema.map(({ id, type, ordinal }) => ({ id, type, ordinal })), [ + { id: "model", type: "MODEL", ordinal: 1 }, + { id: "image", type: "IMAGE", ordinal: 2 }, + ]); +}); + +test("compatible frontend slots with the same name are coalesced", () => { + const schema = mergePipeSchemas([ + [{ id: "string-a", name: "string", type: "STRING", ordinal: 1 }], + [{ id: "string-b", name: "string", type: "STRING", ordinal: 1 }], + ]); + assert.equal(schema.length, 1); + assert.equal(schema[0].id, "string-a"); + assert.deepEqual(schema[0].aliases, ["string-b"]); +}); + +test("empty merge nodes use a compact deterministic height", () => { + assert.equal(compactMergeNodeHeight(0), 88); + assert.equal(compactMergeNodeHeight(2), 132); +}); + +test("reload drops wireless merge ghosts that claim to be unconnected wired sources", () => { + const sources = [ + { key: "pipe_001", mode: "wireless", address: "root/source-a", label: "Source A" }, + { key: "pipe_002", mode: "wireless", address: "root/source-b", label: "Source B" }, + { key: "pipe_007", mode: "wired", address: null, label: "Wired 3" }, + ]; + + assert.deepEqual(normalizeMergeSources(sources, new Set()), sources.slice(0, 2)); + assert.deepEqual( + normalizeMergeSources(sources, new Set(["pipe_007"])), + sources, + ); +}); + +test("reload identifies duplicate dynamic inputs while retaining the linked copy", () => { + const inputs = [ + { name: "pipe_007", link: null }, + { name: "bv_smart_pipe_merge_config_json", link: null }, + { name: "pipe_007", link: 42 }, + { name: "bv_add_pipe_source", link: null }, + ]; + + assert.deepEqual(duplicateDynamicInputIndexes(inputs), [0]); +}); diff --git a/tests/smart_pipe_routing.test.mjs b/tests/smart_pipe_routing.test.mjs new file mode 100644 index 0000000..68739b4 --- /dev/null +++ b/tests/smart_pipe_routing.test.mjs @@ -0,0 +1,481 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + cloneRouteRegistryPrefix, + detachedHostRouting, + compareRoutingNumbers, + crossScopeDescriptors, + executionScope, + logicalAddress, + materializeAddressedPipeLinks, + materializeSmartPipeMergeSources, + materializeWirelessPipeLinks, + parseLogicalAddress, + preferredHostRoutingName, + promptModeState, + predecessorChoiceRoutes, + prunePromptBranches, + reconcileRouteRegistryDestinations, + remapPromptOutputLinks, + routingChoiceLabel, + routingDisplayNumbers, + uniqueHostName, + uniqueRoutingName, + routingCollisionOwner, + validateMaterializedPipeGraph, +} from "../js/bv_smart_pipe_routing.js"; + +function pipe(name, nodeId, mode = "root", predecessorId = null, inputs = {}) { + return { + class_type: "BV Smart Pipe", + inputs: { + bv_smart_pipe_route_json: JSON.stringify({ version: 1, name, nodeId, mode, predecessorId }), + ...inputs, + }, + }; +} + +test("routing names receive deterministic human-readable suffixes", () => { + assert.equal(uniqueRoutingName("Pipe", new Set(["Pipe", "Pipe_1"])), "Pipe_2"); +}); + +test("an initialized pipe keeps its name when a copied newcomer collides", () => { + const incumbent = { title: "Pipe", __bvLastRoutingTitle: "Pipe" }; + const newcomer = { title: "Pipe" }; + assert.equal(routingCollisionOwner(incumbent, newcomer), incumbent); + assert.equal(routingCollisionOwner(newcomer, incumbent), incumbent); +}); + +test("execution scope follows the expanded subgraph instance path", () => { + assert.equal(executionScope("12:45:7"), "12:45"); + assert.equal(executionScope("7"), ""); +}); + +test("root-scope pipes are not repeated as cross-scope choices", () => { + const own = { address: "root/self", hostPath: [] }; + const rootPeer = { address: "root/peer", hostPath: [] }; + const nestedPeer = { address: "root/host/peer", hostPath: ["host"] }; + assert.deepEqual(crossScopeDescriptors([own, rootPeer, nestedPeer], own), [nestedPeer]); +}); + +test("logical addresses survive numeric execution-id changes", () => { + const address = logicalAddress("root/id", ["host one", "nested"], "pipe/id"); + assert.deepEqual(parseLogicalAddress(address), ["root/id", "host one", "nested", "pipe/id"]); + assert.equal(uniqueHostName("Detailer", new Set(["Detailer"])), "Detailer_1"); +}); + +test("copying a subgraph host clones its route subtree and remaps internal predecessors", () => { + const registry = { + "root/old/source": { predecessorAddress: "root/external" }, + "root/old/target": { predecessorAddress: "root/old/source", projection: { resolvedSlots: [{ id: "slot" }] } }, + "root/other/pipe": { predecessorAddress: "root/external" }, + }; + assert.equal(cloneRouteRegistryPrefix(registry, "root/old", "root/new"), 2); + assert.equal(registry["root/new/source"].predecessorAddress, "root/external"); + assert.equal(registry["root/new/target"].predecessorAddress, "root/new/source"); + assert.notEqual(registry["root/new/target"].projection, registry["root/old/target"].projection); + assert.equal(Object.keys(registry).length, 5); +}); + +test("deleted destinations are pruned after a grace period and restored by undo", () => { + const registry = { + "root/live": { predecessorAddress: "root/source" }, + "root/deleted": { predecessorAddress: "root/source" }, + }; + const state = { missingSince: new Map(), backups: new Map() }; + reconcileRouteRegistryDestinations(registry, new Set(["root/live"]), state, 100, 1000); + assert.ok(registry["root/deleted"]); + reconcileRouteRegistryDestinations(registry, new Set(["root/live"]), state, 1200, 1000); + assert.equal(registry["root/deleted"], undefined); + assert.ok(state.backups.has("root/deleted")); + reconcileRouteRegistryDestinations(registry, new Set(["root/live", "root/deleted"]), state, 1300, 1000); + assert.ok(registry["root/deleted"]); + assert.equal(state.backups.has("root/deleted"), false); +}); + +test("nested subgraph addresses materialize through every host level", () => { + const source = logicalAddress("root", ["outer", "inner"], "source"); + const target = logicalAddress("root", [], "target"); + const prompt = { "5:8:2": pipe("Nested", "source"), "9": pipe("Root", "target") }; + materializeAddressedPipeLinks(prompt, { "5:8:2": source, "9": target }, { + [target]: { predecessorAddress: source }, + }); + assert.deepEqual(prompt["9"].inputs.pipe, ["5:8:2", 0]); +}); + +test("copied subgraph hosts detach shared routing metadata before UUID repair", () => { + const shared = { id: "shared", name: "Subgraph" }; + const first = detachedHostRouting(shared); + const second = detachedHostRouting(shared); + second.id = "copy"; + assert.equal(shared.id, "shared"); + assert.equal(first.id, "shared"); + assert.equal(second.id, "copy"); +}); + +test("subgraph definition renames replace stale host routing names", () => { + assert.equal(preferredHostRoutingName({ + storedName: "New Subgraph_1", + storedDefinitionName: "New Subgraph", + title: "New Subgraph_1", + definitionName: "Test123", + }), "Test123"); + assert.equal(preferredHostRoutingName({ + storedName: "New Subgraph_1", + title: "New Subgraph_1", + definitionName: "Test123", + }), "Test123"); +}); + +test("intentional instance titles remain independent from unchanged definitions", () => { + assert.equal(preferredHostRoutingName({ + storedName: "Face Detailer", + storedDefinitionName: "Detailer", + title: "Eye Detailer", + lastAppliedTitle: "Face Detailer", + definitionName: "Detailer", + }), "Eye Detailer"); +}); + +test("address registry materializes root to subgraph and instance to instance links", () => { + const root = logicalAddress("root", [], "root-pipe"); + const first = logicalAddress("root", ["face"], "inner-pipe"); + const second = logicalAddress("root", ["eyes"], "inner-pipe"); + const prompt = { + "7": pipe("Root", "root-pipe"), + "12:4": pipe("Face", "inner-pipe"), + "25:4": pipe("Eyes", "inner-pipe"), + }; + materializeAddressedPipeLinks(prompt, { "7": root, "12:4": first, "25:4": second }, { + [first]: { predecessorAddress: root }, + [second]: { predecessorAddress: first }, + }); + assert.deepEqual(prompt["12:4"].inputs.pipe, ["7", 0]); + assert.deepEqual(prompt["25:4"].inputs.pipe, ["12:4", 0]); +}); + +test("address routing fails closed and physical links still win", () => { + const first = logicalAddress("root", ["first"], "pipe"); + const second = logicalAddress("root", ["second"], "pipe"); + const prompt = { + "10:1": pipe("First", "pipe"), + "20:1": pipe("Second", "pipe", "root", null, { pipe: ["99", 0] }), + }; + materializeAddressedPipeLinks(prompt, { "10:1": first, "20:1": second }, { + [second]: { predecessorAddress: "root/missing/pipe" }, + }); + assert.deepEqual(prompt["20:1"].inputs.pipe, ["99", 0]); + assert.throws(() => materializeAddressedPipeLinks( + { "10:1": pipe("First", "pipe"), "20:1": pipe("Second", "pipe") }, + { "10:1": first, "20:1": second }, + { [second]: { predecessorAddress: "root/missing/pipe" } }, + ), /predecessor .*missing/); +}); + +test("a downstream wireless route skips a bypassed Smart Pipe and keeps the preceding values", () => { + const base = logicalAddress("root", [], "base"); + const bypassed = logicalAddress("root", [], "bypassed"); + const downstream = logicalAddress("root", [], "downstream"); + const prompt = { + "1": pipe("Base", "base"), + "3": pipe("Downstream", "downstream"), + }; + + materializeAddressedPipeLinks(prompt, { "1": base, "3": downstream }, { + [bypassed]: { predecessorAddress: base }, + [downstream]: { predecessorAddress: bypassed }, + }, { bypassedAddresses: new Set([bypassed]) }); + + assert.deepEqual(prompt["3"].inputs.pipe, ["1", 0]); +}); + +test("a muted addressed predecessor prunes its dependent branch for this prompt only", () => { + const base = logicalAddress("root", [], "base"); + const muted = logicalAddress("root", [], "muted"); + const downstream = logicalAddress("root", [], "downstream"); + const prompt = { + "1": pipe("Base", "base"), + "3": pipe("Downstream", "downstream"), + }; + + const modeState = { mutedAddresses: new Set([muted]), prunedExecutionIds: new Set() }; + materializeAddressedPipeLinks(prompt, { "1": base, "3": downstream }, { + [muted]: { predecessorAddress: base }, + [downstream]: { predecessorAddress: muted }, + }, modeState); + prunePromptBranches(prompt, modeState.prunedExecutionIds); + assert.deepEqual(Object.keys(prompt), ["1"]); +}); + +test("hidden output gaps remap visible LiteGraph indexes to backend ordinals", () => { + const prompt = { + source: pipe("Source", "source"), + consumer: { class_type: "Consumer", inputs: { value: ["source", 1], untouched: ["other", 0] } }, + }; + remapPromptOutputLinks(prompt, { source: { 0: 0, 1: 2 } }); + assert.deepEqual(prompt.consumer.inputs.value, ["source", 2]); + assert.deepEqual(prompt.consumer.inputs.untouched, ["other", 0]); +}); + +test("the final compiler rejects cycles spanning registry and local routes", () => { + const prompt = { + "1": pipe("First", "first", "follow", "second"), + "2": pipe("Second", "second"), + }; + materializeAddressedPipeLinks(prompt, { + "1": logicalAddress("scope", [], "first"), + "2": logicalAddress("scope", [], "second"), + }, { + [logicalAddress("scope", [], "second")]: { predecessorAddress: logicalAddress("scope", [], "first") }, + }); + materializeWirelessPipeLinks(prompt); + assert.throws(() => validateMaterializedPipeGraph(prompt), /wired and wireless connections/); +}); + +test("display numbers represent chain and position while forks remain unique", () => { + const labels = routingDisplayNumbers([ + { nodeId: "base", name: "Base", mode: "root" }, + { nodeId: "lora", name: "LoRA", mode: "follow", predecessorId: "base" }, + { nodeId: "control", name: "Control", mode: "follow", predecessorId: "base" }, + { nodeId: "second", name: "Second", mode: "root" }, + { nodeId: "second-next", name: "Second Next", mode: "follow", predecessorId: "second" }, + ]); + assert.equal(labels.get("base"), "1.1"); + assert.match(labels.get("lora"), /^1\.1\.[12]$/); + assert.match(labels.get("control"), /^1\.1\.[12]$/); + assert.notEqual(labels.get("lora"), labels.get("control")); + assert.equal(labels.get("second"), "2.1"); + assert.equal(labels.get("second-next"), "2.2"); +}); + +test("a merge starts a new tree after all of its source trees", () => { + const labels = routingDisplayNumbers([ + { nodeId: "first", name: "Tree 1", mode: "root" }, + { nodeId: "second", name: "Tree 2", mode: "root" }, + { nodeId: "merge", name: "Merge", mode: "merge", sourceIds: ["first", "second"] }, + { nodeId: "after", name: "After Merge", mode: "follow", predecessorId: "merge" }, + { nodeId: "branch-a", name: "Branch A", mode: "follow", predecessorId: "after" }, + { nodeId: "branch-b", name: "Branch B", mode: "follow", predecessorId: "after" }, + ]); + assert.equal(labels.get("first"), "1.1"); + assert.equal(labels.get("second"), "2.1"); + assert.equal(labels.get("merge"), "3.1"); + assert.equal(labels.get("after"), "3.2"); + assert.equal(labels.get("branch-a"), "3.2.1"); + assert.equal(labels.get("branch-b"), "3.2.2"); +}); + +test("the destination tree does not consume a hidden number in its predecessor menu", () => { + const routes = [ + { nodeId: "first", name: "Tree 1", mode: "root" }, + { nodeId: "second", name: "Tree 2", mode: "root" }, + { nodeId: "merge", name: "Merge", mode: "merge", sourceIds: ["first", "second"] }, + { nodeId: "current", name: "BV Smart Pipe", mode: "root" }, + { nodeId: "dependent", name: "Dependent", mode: "follow", predecessorId: "current" }, + ]; + const globalLabels = routingDisplayNumbers(routes); + assert.equal(globalLabels.get("merge"), "3.1"); + assert.equal(globalLabels.get("current"), "4.1"); + const labels = routingDisplayNumbers(predecessorChoiceRoutes(routes, "current")); + assert.equal(labels.has("current"), false); + assert.equal(labels.has("dependent"), false); + assert.equal(labels.get("first"), "1.1"); + assert.equal(labels.get("second"), "2.1"); + assert.equal(labels.get("merge"), "3.1"); +}); + +test("multiple independent chains are numbered by chain before position", () => { + const labels = routingDisplayNumbers([ + { nodeId: "dashboard-1", name: "Dashboard2_1", mode: "root" }, + { nodeId: "smart-1", name: "BV Smart Pipe_1", mode: "follow", predecessorId: "dashboard-1" }, + { nodeId: "pipe", name: "Pipe", mode: "follow", predecessorId: "smart-1" }, + { nodeId: "dashboard-2", name: "Dashboard2_2", mode: "root" }, + { nodeId: "dashboard-3", name: "Dashboard2_3", mode: "follow", predecessorId: "dashboard-2" }, + ]); + assert.equal(labels.get("dashboard-1"), "1.1"); + assert.equal(labels.get("smart-1"), "1.2"); + assert.equal(labels.get("pipe"), "1.3"); + assert.equal(labels.get("dashboard-2"), "2.1"); + assert.equal(labels.get("dashboard-3"), "2.2"); +}); + +test("dropdown labels indent children by their chain position", () => { + assert.equal(routingChoiceLabel("1.1", "Root"), "1.1. Root"); + assert.equal(routingChoiceLabel("1.2", "Child"), "\u00a0\u00a0\u00a01.2. Child"); + assert.equal(routingChoiceLabel("1.3", "Grandchild"), "\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01.3. Grandchild"); + assert.equal(routingChoiceLabel("1.1.2", "Fork"), "\u00a0\u00a0\u00a01.1.2. Fork"); +}); + +test("dropdown route numbers sort by chain, position and fork", () => { + const numbers = ["1.3", "2.2", "1.1.2", "2.1", "1.1", "1.1.1"]; + assert.deepEqual(numbers.sort(compareRoutingNumbers), ["1.1", "1.1.1", "1.1.2", "1.3", "2.1", "2.2"]); +}); + +test("wireless predecessors become prompt-level pipe links", () => { + const prompt = { "1": pipe("Base", "base"), "2": pipe("LoRA", "lora", "follow", "base") }; + materializeWirelessPipeLinks(prompt); + assert.deepEqual(prompt["2"].inputs.pipe, ["1", 0]); +}); + +test("local wireless routing skips a bypassed Smart Pipe", () => { + const prompt = { + "1": pipe("Base", "base"), + "3": pipe("Downstream", "downstream", "follow", "bypassed"), + }; + materializeWirelessPipeLinks(prompt, { + routesByScopedNodeId: new Map([ + ["\u0000bypassed", { name: "Bypassed", nodeId: "bypassed", mode: "follow", predecessorId: "base" }], + ]), + bypassedScopedNodeIds: new Set(["\u0000bypassed"]), + }); + assert.deepEqual(prompt["3"].inputs.pipe, ["1", 0]); +}); + +test("local wireless routing skips a bypassed Smart Pipe whose predecessor is a Merge", () => { + const prompt = { + "2": { class_type: "BV Smart Pipe Merge", inputs: {} }, + "4": pipe("Downstream", "downstream", "follow", "bypassed"), + }; + materializeWirelessPipeLinks(prompt, { + routesByScopedNodeId: new Map([ + ["\u0000bypassed", { name: "After Merge", nodeId: "bypassed", mode: "root", predecessorId: null }], + ]), + bypassedScopedNodeIds: new Set(["\u0000bypassed"]), + bypassPredecessorsByScopedNodeId: new Map([ + ["\u0000bypassed", { executionId: "2" }], + ]), + }); + assert.deepEqual(prompt["4"].inputs.pipe, ["2", 0]); +}); + +test("a muted local wireless predecessor prunes its dependent branch", () => { + const prompt = { + "1": pipe("Base", "base"), + "3": pipe("Downstream", "downstream", "follow", "muted"), + }; + const modeState = { + routesByScopedNodeId: new Map([ + ["\u0000muted", { name: "Muted", nodeId: "muted", mode: "follow", predecessorId: "base" }], + ]), + mutedScopedNodeIds: new Set(["\u0000muted"]), + prunedExecutionIds: new Set(), + }; + materializeWirelessPipeLinks(prompt, modeState); + prunePromptBranches(prompt, modeState.prunedExecutionIds); + assert.deepEqual(Object.keys(prompt), ["1"]); +}); + +test("the same subgraph definition resolves independently per instance", () => { + const prompt = { + "10:1": pipe("Base", "base"), "10:2": pipe("Next", "next", "follow", "base"), + "20:1": pipe("Base", "base"), "20:2": pipe("Next", "next", "follow", "base"), + }; + materializeWirelessPipeLinks(prompt); + assert.deepEqual(prompt["10:2"].inputs.pipe, ["10:1", 0]); + assert.deepEqual(prompt["20:2"].inputs.pipe, ["20:1", 0]); +}); + +test("a physical pipe link wins over wireless metadata", () => { + const prompt = { "1": pipe("Base", "base"), "2": pipe("Next", "next", "follow", "missing", { pipe: ["9", 0] }) }; + materializeWirelessPipeLinks(prompt); + assert.deepEqual(prompt["2"].inputs.pipe, ["9", 0]); +}); + +test("merge sources materialize in configured order and preserve physical links", () => { + const prompt = { + "1": pipe("A", "a"), + "2": pipe("B", "b"), + "3": { class_type: "BV Smart Pipe Merge", inputs: { pipe_002: ["9", 0] } }, + }; + materializeSmartPipeMergeSources(prompt, { "1": "root/a", "2": "root/b", "3": "root/merge" }, { + "root/merge": { kind: "merge", sources: [ + { key: "pipe_001", mode: "wireless", address: "root/a" }, + { key: "pipe_002", mode: "wireless", address: "root/b" }, + ] }, + }); + assert.deepEqual(prompt["3"].inputs.pipe_001, ["1", 0]); + assert.deepEqual(prompt["3"].inputs.pipe_002, ["9", 0]); +}); + +test("a merge omits one muted source but keeps the remaining source", () => { + const prompt = { + "2": pipe("Active", "active"), + "3": { class_type: "BV Smart Pipe Merge", inputs: { + bv_smart_pipe_merge_json: JSON.stringify([ + { key: "pipe_001", mode: "wired", address: "root/muted" }, + { key: "pipe_002", mode: "wireless", address: "root/active" }, + ]), + pipe_001: ["1", 0], + } }, + }; + const modeState = { mutedAddresses: new Set(["root/muted"]), prunedExecutionIds: new Set() }; + materializeSmartPipeMergeSources(prompt, { + "1": "root/muted", "2": "root/active", "3": "root/merge", + }, { + "root/merge": { kind: "merge", sources: [ + { key: "pipe_001", mode: "wired", address: "root/muted" }, + { key: "pipe_002", mode: "wireless", address: "root/active" }, + ] }, + }, modeState); + assert.equal(prompt["3"].inputs.pipe_001, undefined); + assert.deepEqual(prompt["3"].inputs.pipe_002, ["2", 0]); + assert.deepEqual(JSON.parse(prompt["3"].inputs.bv_smart_pipe_merge_json).map((source) => source.key), ["pipe_002"]); + assert.equal(modeState.prunedExecutionIds.size, 0); +}); + +test("a muted Merge is included in the prompt mute snapshot", () => { + const state = promptModeState([ + { kind: "pipe", address: "root/pipe", executionId: "1", node: { mode: 0 } }, + { kind: "merge", address: "root/merge", executionId: "2", node: { mode: 2 } }, + ]); + assert.deepEqual([...state.mutedAddresses], ["root/merge"]); + assert.deepEqual([...state.prunedExecutionIds], ["2"]); +}); + +test("a pipe following a muted Merge is pruned instead of reporting a missing cross-scope predecessor", () => { + const merge = "root/merge"; + const downstream = "root/downstream"; + const state = promptModeState([ + { kind: "merge", address: merge, executionId: "2", node: { mode: 2 } }, + { kind: "pipe", address: downstream, executionId: "3", node: { mode: 0 } }, + ]); + const prompt = { "3": pipe("Downstream", "downstream") }; + materializeAddressedPipeLinks(prompt, { "2": merge, "3": downstream }, { + [downstream]: { predecessorAddress: merge }, + }, state); + prunePromptBranches(prompt, state.prunedExecutionIds); + assert.deepEqual(prompt, {}); +}); + +test("an all-muted merge and its dependent consumers are pruned only from the API prompt", () => { + const prompt = { + "3": { class_type: "BV Smart Pipe Merge", inputs: { pipe_001: ["1", 0] } }, + "4": pipe("After Merge", "after", "root", null, { pipe: ["3", 0] }), + "5": { class_type: "Consumer", inputs: { value: ["4", 1] } }, + "6": { class_type: "Independent", inputs: {} }, + }; + const modeState = { mutedAddresses: new Set(["root/muted"]), prunedExecutionIds: new Set() }; + materializeSmartPipeMergeSources(prompt, { "1": "root/muted", "3": "root/merge" }, { + "root/merge": { kind: "merge", sources: [ + { key: "pipe_001", mode: "wired", address: "root/muted" }, + ] }, + }, modeState); + prunePromptBranches(prompt, modeState.prunedExecutionIds); + assert.deepEqual(Object.keys(prompt), ["6"]); +}); + +test("merge source cycles are rejected together with ordinary pipe cycles", () => { + const prompt = { + "1": { class_type: "BV Smart Pipe Merge", inputs: { pipe_001: ["2", 0] } }, + "2": { class_type: "BV Smart Pipe Merge", inputs: { pipe_001: ["1", 0] } }, + }; + assert.throws(() => validateMaterializedPipeGraph(prompt), /Cycle/); +}); + +test("missing predecessors and cycles fail closed", () => { + assert.throws(() => materializeWirelessPipeLinks({ "1": pipe("Next", "next", "follow", "missing") }), /predecessor is missing/); + assert.throws(() => materializeWirelessPipeLinks({ + "1": pipe("A", "a", "follow", "b"), + "2": pipe("B", "b", "follow", "a"), + }), /cycle/); +}); diff --git a/tests/smart_pipe_slots.test.mjs b/tests/smart_pipe_slots.test.mjs new file mode 100644 index 0000000..0fb50c2 --- /dev/null +++ b/tests/smart_pipe_slots.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { moveMarkedPortToEnd, nextFreeOrdinal, promoteConnectedInheritedSlots, promoteInheritedSlot, resolveLocalSlotNameCollisions, retainedMissingSlots, reusableSmartPipePortIndex, smartPipeSlotName, uniqueSmartPipeSlotName, updateSmartPipePort } from "../js/bv_smart_pipe_slots.js"; + +test("menu-created inputs remain above the Add Slot connector", () => { + const pipe = { name: "pipe" }; + const addSlot = { name: "bv_add_slot", bvAddSlot: true }; + const newSlot = { name: "v_001", bvSlotId: "new" }; + const ports = [pipe, addSlot, newSlot]; + moveMarkedPortToEnd(ports, (port) => port.bvAddSlot); + assert.deepEqual(ports, [pipe, newSlot, addSlot]); +}); + +test("resolved inherited slot replaces its stale missing presentation", () => { + const missing = { + name: "v_001", + label: "⚠ Missing: new_slot 1", + localized_name: "⚠ Missing: new_slot 1", + type: "STRING", + bvSlotId: "slot-1", + link: 42, + }; + + const resolved = updateSmartPipePort(missing, "new_slot 1", "STRING", "slot-1"); + + assert.notStrictEqual(resolved, missing); + assert.equal(resolved.label, "new_slot 1"); + assert.equal(resolved.localized_name, "new_slot 1"); + assert.equal(resolved.link, 42); +}); + +test("copied connected ports with localized names are adopted instead of duplicated", () => { + const ports = [ + { name: "pipe", type: "BV_SMART_PIPE" }, + { name: "out_001", label: "string", localized_name: "out_001", type: "*", links: [42] }, + { name: "out_002", label: "string", localized_name: "string", type: "STRING", links: null }, + ]; + assert.equal(reusableSmartPipePortIndex(ports, { + slotId: "slot-1", portName: "out_002", label: "string", type: "STRING", + }), 1); +}); + +test("removed upstream slots remain only when used downstream", () => { + const previous = [ + { id: "still-present" }, + { id: "used-removed", showOutput: true }, + { id: "unused-removed" }, + ]; + + const missing = retainedMissingSlots(previous, new Set(["still-present"]), (slot) => Boolean(slot.showOutput)); + + assert.deepEqual(missing.map((slot) => slot.id), ["used-removed"]); +}); + +test("connecting a missing inherited input promotes it to a local slot", () => { + const state = { + localSlots: [], + inheritedSlots: [{ id: "slot-1", name: "image", ordinal: 3, type: "*", showInput: true, showOutput: true, missing: true }], + }; + const promoted = promoteInheritedSlot(state, "slot-1", "IMAGE"); + assert.deepEqual(promoted, { + id: "slot-1", name: "image", ordinal: 3, type: "IMAGE", showInput: true, showOutput: true, missing: false, + }); + assert.deepEqual(state.inheritedSlots, []); + assert.equal(state.localSlots[0], promoted); +}); + +test("disconnecting a pipe promotes already connected inherited inputs only", () => { + const state = { + localSlots: [], + inheritedSlots: [ + { id: "connected", name: "prompt", ordinal: 1, type: "STRING", showInput: true, missing: false }, + { id: "empty", name: "model", ordinal: 2, type: "MODEL", showInput: true, missing: false }, + ], + }; + promoteConnectedInheritedSlots(state, new Set(["connected"])); + assert.deepEqual(state.localSlots.map((slot) => slot.id), ["connected"]); + assert.equal(state.localSlots[0].missing, false); + assert.deepEqual(state.inheritedSlots.map((slot) => slot.id), ["empty"]); +}); + +test("new slots do not reuse a deleted ordinal", () => { + assert.equal(nextFreeOrdinal(new Set([1, 2]), 4, 100), 4); + assert.equal(nextFreeOrdinal(new Set([1, 2, 4]), 4, 100), 5); + assert.equal(nextFreeOrdinal(new Set([100]), 100, 100), undefined); +}); + +test("source output labels become stable slot names", () => { + assert.equal(smartPipeSlotName("Positive Conditioning"), "positive_conditioning"); + assert.equal(smartPipeSlotName(" IMAGE "), "image"); + assert.equal(smartPipeSlotName("***"), "new_slot"); +}); + +test("derived slot names receive deterministic suffixes", () => { + const used = new Set(["image", "image_2"]); + assert.equal(uniqueSmartPipeSlotName("image", used), "image_3"); + assert.equal(uniqueSmartPipeSlotName("model", used), "model"); +}); + +test("different slot IDs with the same name remain separate and local names are suffixed", () => { + const localSlots = [ + { id: "local-image", name: "image" }, + { id: "local-mask", name: "mask" }, + ]; + const renamed = resolveLocalSlotNameCollisions(localSlots, ["image", "image_2"]); + assert.deepEqual(localSlots.map((slot) => slot.name), ["image_3", "mask"]); + assert.deepEqual(renamed, [{ id: "local-image", from: "image", to: "image_3" }]); +}); diff --git a/tests/subgraph_ui_projection.test.mjs b/tests/subgraph_ui_projection.test.mjs new file mode 100644 index 0000000..5d2cb70 --- /dev/null +++ b/tests/subgraph_ui_projection.test.mjs @@ -0,0 +1,378 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { projectSubgraphUIPresentation, synchronizeDynamicComboHost } from "../js/bv_subgraph_ui_projection.js"; + +test("promoted heading presentation is copied to the outer subgraph widget", () => { + const draw = () => {}; + const computeSize = () => [240, 52]; + const sourceWidget = { name: "value", type: "BV_HEADING", draw, computeSize, __bvNode: { id: 7 } }; + const targetWidget = { name: "value", type: "text" }; + const sourceNode = { getWidgetFromSlot: () => sourceWidget }; + const hostInput = { name: "value", widgetId: "host:1:value", _subgraphSlot: { linkIds: [10] } }; + const host = { + inputs: [hostInput], + widgets: [targetWidget], + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: {} }) }) }, + getWidgetFromSlot: () => targetWidget, + expandToFitContent() {}, + setDirtyCanvas() {}, + }; + + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(targetWidget.type, "text"); + assert.equal(targetWidget.draw, draw); + assert.equal(targetWidget.computeSize, computeSize); + assert.equal(targetWidget.__bvNode, sourceWidget.__bvNode); +}); + +test("ordinary promoted widgets remain untouched", () => { + const sourceWidget = { name: "seed", type: "number" }; + const targetWidget = { name: "seed", type: "number" }; + const sourceNode = { getWidgetFromSlot: () => sourceWidget }; + const host = { + inputs: [{ widgetId: "host:1:seed", _subgraphSlot: { linkIds: [2] } }], + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: {} }) }) }, + getWidgetFromSlot: () => targetWidget, + }; + assert.equal(projectSubgraphUIPresentation(host), 0); +}); + +test("presentation projects through a nested promoted subgraph widget", () => { + const draw = () => {}; + const sourceWidget = { name: "value", type: "BV_DIVIDER", draw, computeSize: () => [220, 18] }; + const innerTarget = { name: "divider", type: "number" }; + const innerInput = { name: "divider", widgetId: "inner", _subgraphSlot: { linkIds: [1] } }; + const sourceNode = { getWidgetFromSlot: () => sourceWidget }; + const innerHost = { + inputs: [innerInput], widgets: [innerTarget], + isSubgraphNode: () => true, + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: {} }) }) }, + getWidgetFromSlot: () => innerTarget, + }; + const outerTarget = { name: "divider", type: "number" }; + const outerHost = { + inputs: [{ name: "divider", widgetId: "outer", _subgraphSlot: { linkIds: [2] } }], + widgets: [outerTarget], + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: innerHost, input: innerInput }) }) }, + getWidgetFromSlot: () => outerTarget, + }; + assert.equal(projectSubgraphUIPresentation(outerHost), 1); + assert.equal(outerTarget.type, "number"); + assert.equal(outerTarget.draw, draw); +}); + +test("linked exposed heading creates one persistent editable host widget", () => { + const draw = () => {}; + const sourceWidget = { name: "value", type: "BV_HEADING", value: "Title", draw }; + const sourceNode = { + id: 9, + properties: { bvLayoutId: "heading-id" }, + __bvPresentationWidget: sourceWidget, + getWidgetFromSlot: () => null, + }; + const input = { name: "text", _subgraphSlot: { linkIds: [3] } }; + const host = { + inputs: [input], widgets: [], properties: {}, + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: { name: "value" } }) }) }, + addWidget(type, name, value, callback, options) { + const widget = { type, name, value, callback, options }; + this.widgets.push(widget); + return widget; + }, + }; + + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets.length, 1); + assert.equal(host.widgets[0].type, "text"); + assert.equal(host.widgets[0].draw, draw); + host.widgets[0].callback("Changed"); + assert.equal(host.properties.bvSubgraphUIValues["heading-id:value"], "Changed"); +}); + +test("linked exposed divider creates a non-editable custom host widget", () => { + const sourceWidget = { type: "BV_DIVIDER", value: 2, draw: () => {} }; + const sourceNode = { id: 4, __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + const host = { + inputs: [{ name: "divider", _subgraphSlot: { linkIds: [5] } }], widgets: [], + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: { name: "thickness" } }) }) }, + addCustomWidget(widget) { this.widgets.push(widget); return widget; }, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets[0].type, "BV_DIVIDER"); +}); + +test("linked exposed spacer creates a non-editable custom host widget", () => { + const sourceWidget = { type: "BV_SPACER", value: 24, draw: () => {}, computeSize: () => [220, 24] }; + const sourceNode = { id: 5, __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + const host = { + inputs: [{ name: "space", _subgraphSlot: { linkIds: [6] } }], widgets: [], + subgraph: { getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: { name: "height" } }) }) }, + addCustomWidget(widget) { this.widgets.push(widget); return widget; }, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets[0].type, "BV_SPACER"); + assert.deepEqual(host.widgets[0].computeSize(), [220, 24]); +}); + +test("projected spacer size follows the edited outer value", () => { + const sourceWidget = { type: "BV_SPACER", value: 24, draw: () => {} }; + sourceWidget.computeSize = function () { return [220, sourceWidget.value]; }; + const targetWidget = { type: "number", value: 24 }; + const sourceNode = { id: 6, __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + let appliedSize; + const host = { + inputs: [{ name: "Spacer" }], widgets: [targetWidget], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "height" } }], + getWidgetFromSlot: () => targetWidget, + computeSize: () => [220, targetWidget.computeSize(220)[1] + 40], + setSize: (size) => { appliedSize = size; }, + }; + projectSubgraphUIPresentation(host); + targetWidget.callback(48); + assert.deepEqual(targetWidget.computeSize(220), [220, 48]); + assert.deepEqual(appliedSize, [220, 88]); +}); + +test("current ComfyUI host resolves presentation links from subgraph inputs", () => { + const sourceWidget = { type: "BV_HEADING", value: "Heading", draw: () => {} }; + const sourceNode = { + id: 150, + properties: { bvLayoutId: "ad9ede0f-2e7f-4715-ae35-e5049b4a5bf7" }, + __bvPresentationWidget: sourceWidget, + getWidgetFromSlot: () => null, + }; + const host = { + // Real host inputs no longer carry _subgraphSlot. + inputs: [{ name: "value", type: "STRING" }], + widgets: [], properties: {}, + subgraph: { + // The actual definition slot owns linkIds instead. + inputs: [{ id: "a33fdabf-bf71-49ce-a0ae-1ce8e0fb2889", name: "value", linkIds: [131] }], + getLink: () => ({ resolve: () => ({ inputNode: sourceNode, input: { name: "value" } }) }), + }, + addWidget(type, name, value, callback, options) { + const widget = { type, name, value, callback, options }; + this.widgets.push(widget); + return widget; + }, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets.length, 1); + assert.equal(host.widgets[0].value, "Heading"); +}); + +test("current SubgraphNode resolver is authoritative for exposed presentation inputs", () => { + const sourceWidget = { type: "BV_DIVIDER", value: 2, draw: () => {} }; + const sourceNode = { id: 77, __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + const host = { + inputs: [{ name: "divider", type: "INT" }], widgets: [], properties: {}, + subgraph: {}, + resolveSubgraphInputLinks: (slot) => slot === 0 + ? [{ inputNode: sourceNode, input: { name: "thickness" } }] + : [], + addCustomWidget(widget) { this.widgets.push(widget); return widget; }, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets[0].type, "BV_DIVIDER"); +}); + +test("projection supports modern getter-only widget types", () => { + let drawArgs; + const draw = (...args) => { drawArgs = args; }; + const sourceWidget = { type: "BV_HEADING", draw, computeSize: () => [220, 40] }; + const targetWidget = { + get type() { return "text"; }, + y: 12, + get height() { return 40; }, + drawWidget() {}, + }; + const sourceNode = { __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + const host = { + inputs: [{ name: "value" }], widgets: [targetWidget], + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => targetWidget, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(targetWidget.type, "text"); + assert.equal(targetWidget.draw, draw); + targetWidget.drawWidget({}, { width: 300, showText: true }); + assert.equal(drawArgs[2], 300); + assert.equal(drawArgs[3], 12); + assert.deepEqual(targetWidget.computeLayoutSize(), { minWidth: 220, minHeight: 40 }); +}); + +test("ComfyUI-owned slot proxy remains authoritative and keeps native editing", () => { + const sourceDraw = () => {}; + const sourceWidget = { type: "BV_HEADING", value: "Visible", draw: sourceDraw }; + const nativeCallback = () => {}; + const detachedProxy = { + get type() { return "text"; }, + value: "Visible", + callback: nativeCallback, + drawWidget() {}, + }; + const sourceNode = { __bvPresentationWidget: sourceWidget, getWidgetFromSlot: () => null }; + let appliedSize; + const host = { + inputs: [{ name: "value" }], widgets: [], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => detachedProxy, + addWidget(type, name, value, callback, options) { + const widget = { type, name, value, callback, options }; + this.widgets.push(widget); + return widget; + }, + computeSize: () => [240, 120], + setSize: (size) => { appliedSize = size; }, + }; + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(host.widgets.length, 0); + assert.equal(detachedProxy.draw, sourceDraw); + assert.equal(detachedProxy.callback, nativeCallback); + assert.equal(detachedProxy.__bvPresentationType, "BV_HEADING"); + assert.deepEqual(appliedSize, [240, 120]); +}); + +test("heading survives reload before its runtime widget marker is restored", () => { + const sourceWidget = { type: "text", value: "Saved heading", draw: () => {} }; + const sourceNode = { + comfyClass: "BV Subgraph Heading", + getWidgetFromSlot: () => sourceWidget, + }; + let callbackValue; + const proxy = { + get type() { return "text"; }, + value: "Saved heading", + drawWidget() {}, + callback(value) { callbackValue = value; }, + }; + const host = { + inputs: [{ name: "value" }], widgets: [], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => proxy, + }; + + assert.equal(projectSubgraphUIPresentation(host), 1); + assert.equal(proxy.__bvPresentationType, "BV_HEADING"); + let promptCallback; + proxy.onClick({ + e: {}, node: host, + canvas: { prompt(_title, _value, callback) { promptCallback = callback; } }, + }); + promptCallback("Edited heading"); + assert.equal(proxy.value, "Edited heading"); + assert.equal(sourceWidget.value, "Edited heading"); + assert.equal(callbackValue, "Edited heading"); + assert.equal(host.properties.bvSubgraphUIValues["presentation:value"], "Edited heading"); + assert.equal(host.serialize_widgets, true); +}); + +test("projection is idempotent and repairs a proxy renderer reset", () => { + const sourceWidget = { type: "text", value: "Heading", draw: () => {}, computeSize: () => [220, 40] }; + const sourceNode = { type: "BV Subgraph Heading", getWidgetFromSlot: () => sourceWidget }; + const proxy = { get type() { return "text"; }, drawWidget() {}, options: {} }; + let dirtyCalls = 0; + const host = { + inputs: [{ name: "value" }], widgets: [], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => proxy, + setDirtyCanvas: () => { dirtyCalls++; }, + }; + + projectSubgraphUIPresentation(host); + const projectedRenderer = proxy.drawWidget; + const dirtyAfterFirstProjection = dirtyCalls; + projectSubgraphUIPresentation(host); + assert.equal(proxy.drawWidget, projectedRenderer); + assert.equal(dirtyCalls, dirtyAfterFirstProjection); + + proxy.drawWidget = function standardTextRenderer() {}; + projectSubgraphUIPresentation(host); + assert.equal(proxy.drawWidget, projectedRenderer); + assert.ok(dirtyCalls > dirtyAfterFirstProjection); +}); + +test("technical exposed names migrate through the official subgraph rename API", () => { + const sourceWidget = { type: "text", draw: () => {} }; + const sourceNode = { type: "BV Subgraph Heading", getWidgetFromSlot: () => sourceWidget }; + const exposedInput = { id: "stable-slot-id", name: "value", linkIds: [9] }; + const existingHeader = { id: "other-slot", name: "Header", linkIds: [] }; + const hostInput = { name: "value", _subgraphSlot: exposedInput }; + const proxy = { type: "text", drawWidget() {}, options: {} }; + const renameCalls = []; + const host = { + inputs: [hostInput], widgets: [], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => proxy, + subgraph: { + inputs: [exposedInput, existingHeader], + renameInput(input, name) { + renameCalls.push({ id: input.id, name }); + input.name = name; + hostInput.name = name; + }, + }, + }; + + projectSubgraphUIPresentation(host); + assert.deepEqual(renameCalls, [{ id: "stable-slot-id", name: "Header_1" }]); + assert.equal(exposedInput.id, "stable-slot-id"); + assert.deepEqual(exposedInput.linkIds, [9]); + projectSubgraphUIPresentation(host); + assert.equal(renameCalls.length, 1); +}); + +test("deliberately renamed presentation inputs remain untouched", () => { + const sourceWidget = { type: "number", draw: () => {} }; + const sourceNode = { type: "BV Subgraph Spacer", getWidgetFromSlot: () => sourceWidget }; + const exposedInput = { id: "slot", name: "Layout gap", linkIds: [3] }; + let renamed = false; + const host = { + inputs: [{ name: "Layout gap", _subgraphSlot: exposedInput }], widgets: [], properties: {}, + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "height" } }], + getWidgetFromSlot: () => ({ type: "number", drawWidget() {}, options: {} }), + subgraph: { inputs: [exposedInput], renameInput() { renamed = true; } }, + }; + + projectSubgraphUIPresentation(host); + assert.equal(renamed, false); +}); + +test("dynamic combo option changes propagate to exposed host proxies", () => { + const sourceNode = { type: "BV Dynamic Combo" }; + let callbackValue; + const proxy = { + value: "option_a", + options: { values: ["option_a", "option_b"] }, + callback(value) { callbackValue = value; }, + }; + const host = { + inputs: [{ name: "value" }], + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => proxy, + }; + + assert.equal(synchronizeDynamicComboHost(host, sourceNode, ["low", "medium", "high"]), 1); + assert.deepEqual(proxy.options.values, ["low", "medium", "high"]); + assert.equal(proxy.value, "low"); + assert.equal(callbackValue, "low"); +}); + +test("valid exposed dynamic combo selections survive option updates", () => { + const sourceNode = { type: "BV Dynamic Combo" }; + let callbackCalled = false; + const proxy = { + value: "medium", options: {}, + callback() { callbackCalled = true; }, + }; + const host = { + inputs: [{ name: "value" }], + resolveSubgraphInputLinks: () => [{ inputNode: sourceNode, input: { name: "value" } }], + getWidgetFromSlot: () => proxy, + }; + + synchronizeDynamicComboHost(host, sourceNode, ["low", "medium", "high"]); + assert.equal(proxy.value, "medium"); + assert.equal(callbackCalled, false); +}); diff --git a/tests/test_control_center.py b/tests/test_control_center.py new file mode 100644 index 0000000..4e076cb --- /dev/null +++ b/tests/test_control_center.py @@ -0,0 +1,25 @@ +import json +import unittest + +from py.nodes.bv_control_center import BVControlCenterNode + + +class ControlCenterTests(unittest.TestCase): + def test_enabled_control_with_unresolved_group_blocks(self): + config = { + "controls": [{ + "name": "Upscale", + "enabled": True, + "assignments": [{"groupPath": "Root / Upscale", "unresolved": True}], + }] + } + result = BVControlCenterNode.VALIDATE_INPUTS(json.dumps(config)) + self.assertIn("Root / Upscale", result) + + def test_disabled_control_allows_unresolved_group(self): + config = {"controls": [{"enabled": False, "assignments": [{"unresolved": True}]}]} + self.assertTrue(BVControlCenterNode.VALIDATE_INPUTS(json.dumps(config))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dynamic_combo.py b/tests/test_dynamic_combo.py new file mode 100644 index 0000000..7543949 --- /dev/null +++ b/tests/test_dynamic_combo.py @@ -0,0 +1,22 @@ +import unittest + +from py.nodes.bv_subgraph_dynamic_combo import BVDynamicCombo + + +class DynamicComboTests(unittest.TestCase): + def test_rejects_stale_selection(self): + with self.assertRaisesRegex(Exception, "not present"): + BVDynamicCombo().run("old", "new") + + def test_rejects_duplicate_options(self): + with self.assertRaisesRegex(Exception, "duplicate"): + BVDynamicCombo().run("one", "one\none") + + def test_rejects_non_finite_connected_float(self): + prompt = {"consumer": {"inputs": {"value": ["combo", 2]}}} + with self.assertRaisesRegex(Exception, "finite"): + BVDynamicCombo().run("NaN", "NaN", prompt=prompt, unique_id="combo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hex_color.py b/tests/test_hex_color.py new file mode 100644 index 0000000..c436aa9 --- /dev/null +++ b/tests/test_hex_color.py @@ -0,0 +1,32 @@ +import importlib.util +from pathlib import Path +import unittest + + +MODULE_PATH = Path(__file__).parents[1] / "py" / "nodes" / "bv_util_hex_color_int.py" +SPEC = importlib.util.spec_from_file_location("bv_hex_color", MODULE_PATH) +HEX_COLOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(HEX_COLOR) + + +class HexColorTests(unittest.TestCase): + def setUp(self): + self.node = HEX_COLOR.BVHexColorToInt() + + def test_expands_short_rgb(self): + self.assertEqual(self.node.run("#3af"), (0x33AAFF,)) + self.assertEqual(self.node.run("3AF"), (0x33AAFF,)) + + def test_keeps_supported_six_digit_formats(self): + self.assertEqual(self.node.run("#12abEF"), (0x12ABEF,)) + self.assertEqual(self.node.run("0x12abef"), (0x12ABEF,)) + + def test_rejects_alpha_and_invalid_values(self): + for value in ("#12345678", "#12", "not-a-color"): + with self.subTest(value=value): + with self.assertRaises(ValueError): + self.node.run(value) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_latent_dimensions.py b/tests/test_latent_dimensions.py new file mode 100644 index 0000000..a750935 --- /dev/null +++ b/tests/test_latent_dimensions.py @@ -0,0 +1,35 @@ +import unittest + +from py.util.latent_dimensions import combine_ratios, dimensions_for_area, parse_ratios + + +class LatentDimensionTests(unittest.TestCase): + def test_normalizes_ratios(self): + self.assertEqual([(16, 9), (1, 1)], parse_ratios("32:18\n1:1")) + + def test_rejects_normalized_duplicates(self): + with self.assertRaisesRegex(ValueError, "Duplicate aspect ratio"): + parse_ratios("16:9\n32:18") + + def test_requires_a_ratio(self): + with self.assertRaisesRegex(ValueError, "At least one"): + parse_ratios("\n \n") + + def test_combines_standard_and_custom_ratios(self): + self.assertEqual([(1, 1), (16, 9), (5, 4)], combine_ratios([(1, 1), (16, 9)], "5:4")) + + def test_combined_ratios_ignore_custom_standard_duplicates(self): + self.assertEqual([(1, 1), (16, 9)], combine_ratios([(1, 1)], "1:1\n16:9")) + + def test_combined_ratios_require_one_enabled_value(self): + with self.assertRaisesRegex(ValueError, "enabled or entered"): + combine_ratios([], "") + + def test_preserves_approximate_square_area_and_alignment(self): + width, height = dimensions_for_area(1024, (16, 9), 64) + self.assertEqual((1344, 768), (width, height)) + self.assertLess(abs(width * height - 1024 * 1024), 0.02 * 1024 * 1024) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_category.py b/tests/test_prompt_category.py new file mode 100644 index 0000000..3dcff54 --- /dev/null +++ b/tests/test_prompt_category.py @@ -0,0 +1,50 @@ +import importlib.util +from pathlib import Path +import unittest + + +MODULE_PATH = Path(__file__).parents[1] / "py" / "util" / "prompt" / "category.py" +SPEC = importlib.util.spec_from_file_location("bv_prompt_category", MODULE_PATH) +CATEGORY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CATEGORY) + + +class PromptCategoryTests(unittest.TestCase): + def test_comment_removal_preserves_line_break(self): + ast = CATEGORY.parse_prompt_to_ast("red dress ## note\ncinematic lighting") + + self.assertEqual( + CATEGORY.ast_to_plain_text(ast), + "red dress \ncinematic lighting", + ) + self.assertEqual( + CATEGORY.ast_to_plain_text(ast, include_comments=True), + "red dress ## note\ncinematic lighting", + ) + + def test_unclosed_inline_category_reports_location(self): + with self.assertRaisesRegex( + ValueError, + "Unclosed inline category 'face' opened at line 1, column 1", + ): + CATEGORY.parse_prompt_to_ast("@portrait") + + def test_block_directive_rejects_open_inline_category(self): + with self.assertRaisesRegex(ValueError, "before block directive on line 2"): + CATEGORY.parse_prompt_to_ast("@portrait\n@@style\ncinematic") + + def test_unexpected_inline_closer_reports_location(self): + with self.assertRaisesRegex( + ValueError, + "Unexpected inline category closer '@@' at line 1, column 10", + ): + CATEGORY.parse_prompt_to_ast("portrait @@") + + def test_standalone_default_block_directive_remains_valid(self): + ast = CATEGORY.parse_prompt_to_ast("@@\nplain") + + self.assertEqual(CATEGORY.ast_to_plain_text(ast), "plain") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_smart_pipe.py b/tests/test_smart_pipe.py new file mode 100644 index 0000000..de0d123 --- /dev/null +++ b/tests/test_smart_pipe.py @@ -0,0 +1,28 @@ +import json +import unittest + +from py.nodes.bv_smart_pipe import BVSmartPipe + + +class SmartPipeTests(unittest.TestCase): + def test_values_follow_slot_ids_across_chain(self): + schema = [ + {"id": "model-id", "name": "model", "ordinal": 1}, + {"id": "image-id", "name": "image", "ordinal": 2}, + ] + first_route = json.dumps({"nodeId": "first", "name": "Pipe"}) + second_route = json.dumps({"nodeId": "second", "name": "Pipe_1"}) + first = BVSmartPipe().run(json.dumps(schema), first_route, v_001="model-a", v_002="image-a")[0] + second = BVSmartPipe().run(json.dumps(schema), second_route, pipe=first, v_002="image-b")[0] + self.assertEqual({"model-id": "model-a", "image-id": "image-b"}, second["values"]) + self.assertEqual({"model-id": "first", "image-id": "second"}, second["writers"]) + + def test_missing_connected_slot_blocks_validation(self): + schema = [{"id": "gone", "name": "image", "ordinal": 1, "missing": True, "connected": True}] + route = json.dumps({"nodeId": "pipe", "name": "Pipe"}) + result = BVSmartPipe.VALIDATE_INPUTS(json.dumps(schema), route) + self.assertIn("still used", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_smart_pipe_merge.py b/tests/test_smart_pipe_merge.py new file mode 100644 index 0000000..e2d9157 --- /dev/null +++ b/tests/test_smart_pipe_merge.py @@ -0,0 +1,96 @@ +import unittest + +from py.nodes.bv_smart_pipe_merge import BVSmartPipeMerge, merge_smart_pipes + + +def pipe(schema, values, writers, provenance=None): + result = {"version": 2, "schema": schema, "values": values, "writers": writers} + if provenance is not None: + result["provenance"] = provenance + return result + + +class SmartPipeMergeTests(unittest.TestCase): + def test_later_sources_win_for_the_same_stable_id(self): + first = pipe( + [{"id": "image", "name": "image", "ordinal": 1, "type": "IMAGE"}], + {"image": "base"}, + {"image": "writer-a"}, + ) + second = pipe( + [{"id": "image", "name": "image", "ordinal": 4, "type": "IMAGE"}], + {"image": "detail"}, + {"image": "writer-b"}, + ) + merged = merge_smart_pipes([first, second]) + self.assertEqual(merged["values"]["image"], "detail") + self.assertEqual(merged["writers"]["image"], "writer-b") + self.assertEqual(merged["schema"][0]["ordinal"], 1) + + def test_compatible_slots_with_the_same_name_are_coalesced(self): + first = pipe( + [{"id": "image-a", "name": "image", "ordinal": 1, "type": "IMAGE"}], + {"image-a": "a"}, + {"image-a": "writer-a"}, + ) + second = pipe( + [{"id": "image-b", "name": "image", "ordinal": 1, "type": "IMAGE"}], + {"image-b": "b"}, + {"image-b": "writer-b"}, + ) + merged = merge_smart_pipes([first, second]) + self.assertEqual([slot["name"] for slot in merged["schema"]], ["image"]) + self.assertEqual(merged["values"], {"image-a": "b"}) + + def test_same_name_with_incompatible_types_remains_separate(self): + first = pipe( + [{"id": "value-a", "name": "value", "ordinal": 1, "type": "STRING"}], + {"value-a": "text"}, + {"value-a": "writer-a"}, + ) + second = pipe( + [{"id": "value-b", "name": "value", "ordinal": 1, "type": "IMAGE"}], + {"value-b": "image"}, + {"value-b": "writer-b"}, + ) + merged = merge_smart_pipes([first, second]) + self.assertEqual([slot["name"] for slot in merged["schema"]], ["value", "value_2"]) + + def test_inherited_base_does_not_overwrite_a_genuine_branch_write(self): + schema = [{"id": "x", "name": "x", "ordinal": 1}] + changed = pipe(schema, {"x": "changed"}, {"x": "branch-a"}, {"x": ["root", "branch-a"]}) + inherited = pipe(schema, {"x": "base"}, {"x": "root"}, {"x": ["root"]}) + merged = merge_smart_pipes([changed, inherited]) + self.assertEqual(merged["values"]["x"], "changed") + self.assertEqual(merged["writers"]["x"], "branch-a") + + def test_later_divergent_branch_write_still_wins(self): + schema = [{"id": "x", "name": "x", "ordinal": 1}] + first = pipe(schema, {"x": "a"}, {"x": "branch-a"}, {"x": ["root", "branch-a"]}) + second = pipe(schema, {"x": "b"}, {"x": "branch-b"}, {"x": ["root", "branch-b"]}) + merged = merge_smart_pipes([first, second]) + self.assertEqual(merged["values"]["x"], "b") + self.assertEqual(merged["writers"]["x"], "branch-b") + + def test_node_uses_explicit_source_order(self): + first = pipe([{"id": "x", "name": "x", "ordinal": 1}], {"x": 1}, {"x": "a"}) + second = pipe([{"id": "x", "name": "x", "ordinal": 1}], {"x": 2}, {"x": "b"}) + result = BVSmartPipeMerge().run( + '[{"key":"pipe_002"},{"key":"pipe_001"}]', + pipe_001=first, + pipe_002=second, + )[0] + self.assertEqual(result["values"]["x"], 1) + self.assertEqual(result["writers"]["x"], "a") + + def test_configured_missing_source_fails_closed(self): + with self.assertRaisesRegex(ValueError, "pipe_001"): + BVSmartPipeMerge().run('[{"key":"pipe_001","mode":"wireless"}]') + + def test_duplicate_source_addresses_are_rejected(self): + config = '[{"key":"pipe_001","address":"root/a"},{"key":"pipe_002","address":"root/a"}]' + self.assertIn("same source", BVSmartPipeMerge.VALIDATE_INPUTS(config)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/package-lock.json b/ui/package-lock.json index b946bc9..0faf603 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "bv_nodepack", - "version": "1.0.6", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "bv_nodepack", - "version": "1.0.6", + "version": "1.1.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/ui/package.json b/ui/package.json index ccf6a8a..2369b22 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "bv_nodepack", "private": true, - "version": "1.0.6", + "version": "1.1.0", "type": "module", "scripts": { "build": "vite build", diff --git a/ui/src/components/BVPortal.tsx b/ui/src/components/BVPortal.tsx index 563779e..de9205b 100644 --- a/ui/src/components/BVPortal.tsx +++ b/ui/src/components/BVPortal.tsx @@ -1,7 +1,5 @@ import {FC, useEffect, useMemo} from "react"; import * as React from 'react'; -import { getApp } from "../appHelper.js"; -import {collectAllGroups} from "../util/control/collector"; import BVControl from "./control/BVControlComponent"; interface IBVPortalProps { @@ -10,10 +8,7 @@ interface IBVPortalProps { children: React.ReactNode; } -const BvPortal: FC = ({open, onClose, children}) => { - - const comfyApp = getApp(); - +const BvPortal: FC = ({open, onClose}) => { const el = useMemo(() => document.createElement("div"), []); useEffect(() => { @@ -38,44 +33,20 @@ const BvPortal: FC = ({open, onClose, children}) => { return ( { if (e.target === e.currentTarget) onClose(); }} - style={{ - position: "fixed", - inset: 0, - display: "grid", - placeItems: "center", - background: "rgba(0,0,0,0.45)", - zIndex: 2147483647, - }} > e.stopPropagation()} > - onClose()} /> - - - {/*{collectAllGroups(comfyApp).map((group) => (*/} - {/* {group.group.title}*/} - {/*))}*/} - ); }; diff --git a/ui/src/components/control/BVControlComponent.tsx b/ui/src/components/control/BVControlComponent.tsx index 383d45d..3066f44 100644 --- a/ui/src/components/control/BVControlComponent.tsx +++ b/ui/src/components/control/BVControlComponent.tsx @@ -1,98 +1,60 @@ -import * as React from 'react'; -import {BVControlRow, readConfig, writeConfig} from "../../util/control/configHandler"; +import * as React from "react"; +import { FC, useMemo, useState } from "react"; +import { getApp } from "../../appHelper"; +import { collectAllGroups } from "../../util/control/collector"; +import { BVControl, BVControlConfig, readConfig, writeConfig } from "../../util/control/configHandler"; +import { findActiveControlConflicts } from "../../util/control/controlCenterModel.js"; import BVControlRowComponent from "./BVControlRowComponent"; -import {FC, useMemo} from "react"; -import {getApp} from "../../appHelper"; -import {collectAllGroups} from "../../util/control/collector"; -const BVControlComponent: FC<{onClose() : void}> = ({onClose}) => { +function id() { + return globalThis.crypto?.randomUUID?.() ?? `bv-control-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} - const [config, setConfig] = React.useState(readConfig()); - const comfyApp = getApp(); - const groups = useMemo(() => collectAllGroups(comfyApp), [comfyApp, config]); +const BVControlComponent: FC<{ onClose(): void }> = ({ onClose }) => { + const [config, setConfig] = useState(() => { + const current = readConfig(); + const available = new Set(collectAllGroups(getApp()).map((group) => group.id)); + return { + ...current, + controls: current.controls.map((control) => ({ + ...control, + assignments: control.assignments.map((assignment) => ({ ...assignment, unresolved: !available.has(assignment.groupId) })), + })), + }; + }); + const groups = useMemo(() => collectAllGroups(getApp()), [config]); + const conflicts = useMemo(() => findActiveControlConflicts(config), [config]); + const updateControl = (index: number, control: BVControl | null) => { + setConfig({ ...config, controls: control ? config.controls.map((item, itemIndex) => itemIndex === index ? control : item) : config.controls.filter((_, itemIndex) => itemIndex !== index) }); + }; + const addControl = () => { + let name = "New Control"; + let suffix = 2; + while (config.controls.some((control) => control.name === name)) name = `New Control ${suffix++}`; + setConfig({ ...config, controls: [...config.controls, { id: id(), name, enabled: true, assignments: [] }] }); + }; + const save = () => { + const names = config.controls.map((control) => control.name.trim()); + if (names.some((name) => !name) || new Set(names).size !== names.length) return; + const available = new Set(groups.map((group) => group.id)); + const resolved = { + ...config, + controls: config.controls.map((control) => ({ + ...control, + name: control.name.trim(), + assignments: control.assignments.map((assignment) => ({ ...assignment, unresolved: !available.has(assignment.groupId) })), + })), + }; + writeConfig(resolved); + setConfig(resolved); + }; - const addRow = () => { - if (config) { - const baseTitle = "New Row"; - let newTitle = baseTitle; - let counter = 1; - while (config.rows.some(r => r.title === newTitle)) { - newTitle = `${baseTitle} ${counter}`; - counter++; - } - setConfig({rows: [...config.rows, {title: newTitle, entries: []}]}); - } else { - setConfig({rows: [{title: "New Row", entries: []}]}); - } - } - - const saveConfig = () => { - writeConfig(config); - } - - const clearConfig = () => { - writeConfig(null); - setConfig(null); - } - - const onChange = (index: number, row: BVControlRow, del: boolean) => { - if (config) { - if (del) { - setConfig({...config, rows: config.rows.filter((_, i) => i !== index)}); - }else{ - // Prevent duplicate titles - const isDuplicate = config.rows.some((r, i) => i !== index && r.title === row.title); - if (isDuplicate) { - // Option 1: Just don't update if it's a duplicate title - // But we might want to update other parts of the row (entries) - // So we only block if the title specifically is being changed to a duplicate - const oldRow = config.rows[index]; - if (oldRow.title !== row.title) { - // Title changed and it's a duplicate. We could revert the title or auto-increment it. - // For now, let's just not update the title if it's a duplicate. - // Or better: call a function that makes it unique. - let uniqueTitle = row.title; - let counter = 1; - while (config.rows.some((r, i) => i !== index && r.title === uniqueTitle)) { - uniqueTitle = `${row.title} ${counter}`; - counter++; - } - row = {...row, title: uniqueTitle}; - } - } - setConfig({...config, rows: config.rows.map((r, i) => i === index ? row : r)}); - } - } - } - - const cols = (config?.rows.reduce((m, r) => Math.max(m, r.entries.length), 0) ?? 0) + 3; - - return ( - - {config && - - {Array.from({length: cols}, (_, i) => { - if (i === 0) return Title; - if (i === cols - 1) return ; - return Selection {i}; - })} - - {config.rows.map((row, i) => ( - - ))} - - } - - Add Row - Save Config - Clear Config - Close - - - ); + return + BV Control RackUser-defined workflow states× + setConfig({ ...config, forceActive: event.target.checked })} /> Force active after releasing restrictions + {config.controls.map((control, index) => updateControl(index, value)} />)} + + ; }; export default BVControlComponent; diff --git a/ui/src/components/control/BVControlRowComponent.tsx b/ui/src/components/control/BVControlRowComponent.tsx index ec892d2..596863d 100644 --- a/ui/src/components/control/BVControlRowComponent.tsx +++ b/ui/src/components/control/BVControlRowComponent.tsx @@ -1,107 +1,34 @@ -import {FC, useEffect} from "react"; -import {BVControlEntry} from "../../util/control/configHandler"; -import * as React from 'react'; -import {BVControlRow} from "../../util/control/configHandler"; -import {CollectedGroup} from "../../util/control/collector"; +import * as React from "react"; +import { FC } from "react"; +import { CollectedGroup } from "../../util/control/collector"; +import { BVControl, BVControlAction } from "../../util/control/configHandler"; +import type { BVControlConflict } from "../../util/control/controlCenterModel.js"; -const BvControlRowComponent: FC<{ index: number, row: BVControlRow, maxCols: number, onChange(index: number, row: BVControlRow, del: boolean): void, groups: CollectedGroup[]}> = ({index, row, maxCols, onChange, groups}) => { - - useEffect(() => { - const groupTitles = groups.map(g => g.group.title); - const validEntries = row.entries.filter(entry => { - if (entry.kind === "group") { - return groupTitles.includes(entry.ref.title); - } - return true; - }); - - if (validEntries.length !== row.entries.length) { - onChange(index, {...row, entries: validEntries}, false); - } - }, [groups, row.entries, index, onChange, row.title]); - - const onSelectedChange = (entryIndex: number | null, value: string) => { - if (entryIndex === null) { - const newEntry: BVControlEntry = { - kind: "group", - ref: { - title: value - }, - action: "bypass" - }; - onChange(index, {...row, entries: [...row.entries, newEntry]}, false); - return - } - - if (value == "Nothing Selected") { - onChange(index, {...row, entries: row.entries.filter((_, i) => i !== entryIndex)}, false); - return - } - const currentEntry = row.entries[entryIndex]; - const newEntry: BVControlEntry = { - ...currentEntry, - kind: "group", - ref: { - title: value - } - }; - onChange(index, {...row, entries: [...row.entries.slice(0, entryIndex), newEntry, ...row.entries.slice(entryIndex + 1)]}, false); - } - - const onActionChange = (entryIndex: number, action: BVControlEntry["action"]) => { - const currentEntry = row.entries[entryIndex]; - const newEntry: BVControlEntry = { - ...currentEntry, - action: action - }; - onChange(index, {...row, entries: [...row.entries.slice(0, entryIndex), newEntry, ...row.entries.slice(entryIndex + 1)]}, false); - } - - return ( - <> - - onChange(index, {...row, title: e.target.value}, false)}/> - - {row.entries.map((entry, i) => ( - - onSelectedChange(i, e.target.value)} - > - Nothing Selected - {groups.map((group) => { - return {group.group.title}; - })} - - onActionChange(i, e.target.value as BVControlEntry["action"])} - > - Mute - Bypass - - - ))} - - onSelectedChange(null, e.target.value)} - > - Nothing Selected - {groups.map((group) => { - return {group.group.title}; - })} - - - - {Array.from({length: maxCols - row.entries.length - 3}).map((_, i) => ( - - ))} - - onChange(index, row, true)}>Delete - - > - ); +const BVControlRowComponent: FC<{ control: BVControl; groups: CollectedGroup[]; conflicts: BVControlConflict[]; onChange(value: BVControl | null): void }> = ({ control, groups, conflicts, onChange }) => { + const actionLabel = (action: string) => action.charAt(0).toUpperCase() + action.slice(1); + const addAssignment = (groupId: string) => { + const group = groups.find((item) => item.id === groupId); + if (!group || control.assignments.some((item) => item.groupId === groupId)) return; + onChange({ ...control, assignments: [...control.assignments, { groupId, groupPath: group.pathLabel, groupTitle: String(group.group.title || ""), action: "bypass" }] }); + }; + return + onChange({ ...control, name: event.target.value })} />{control.assignments.length} groups onChange(null)}>Delete + {control.assignments.map((assignment, index) => { + const conflict = conflicts.find((item) => item.groupId === assignment.groupId); + const winnerNames = conflict?.entries.filter((entry) => entry.action === conflict.winnerAction).map((entry) => entry.controlName).join(", "); + const conflictText = conflict + ? assignment.action === conflict.winnerAction + ? `Overrides ${[...new Set(conflict.entries.filter((entry) => entry.action !== conflict.winnerAction).map((entry) => actionLabel(entry.action)))].join(", ")}` + : `Overridden by ${actionLabel(conflict.winnerAction)} from ${winnerNames}` + : ""; + return + {assignment.unresolved ? "⚠ " : ""}{assignment.groupPath}{conflictText && ⚠ {conflictText}} + onChange({ ...control, assignments: control.assignments.map((item, itemIndex) => itemIndex === index ? { ...item, action: event.target.value as BVControlAction } : item) })}>ActivateMuteBypass + onChange({ ...control, assignments: control.assignments.filter((_, itemIndex) => itemIndex !== index) })}>× + ; + })} + addAssignment(event.target.value)}>Add group…{groups.map((group) => {group.pathLabel})} + ; }; -export default BvControlRowComponent; +export default BVControlRowComponent; diff --git a/ui/src/components/control/bv_control_center.ts b/ui/src/components/control/bv_control_center.ts index 253ba41..ee2d926 100644 --- a/ui/src/components/control/bv_control_center.ts +++ b/ui/src/components/control/bv_control_center.ts @@ -1,585 +1,196 @@ -import {getApp} from "../../appHelper.js"; -import {BVControlConfig, BVControlEntry, readConfig} from "../../util/control/configHandler"; -import {IBaseWidget, LGraphNode, Subgraph, SubgraphNode} from "../../types/comfyui-frontend-types.augment"; -import { - bypassGroupsByTitle, - muteGroupsByTitle, - unbypassGroupsByTitle, - unmuteGroupsByTitle -} from "../../util/control/stateHandler"; -import { - crawlSubgraphForNode, - getNodeHelper, - getWidgetValue, - showErrorToast -} from "../../util/control/controlHelper"; -import { - ensureSubgraphContainerPatchedFromInnerNodeRetry, findAllSubgraphContainers, getOuterNode, - patchSubgraphContainerPrototype, - renameSubGraphInputSlot -} from "../../util/control/subgraphHandler"; -import {patchGraphAddSingletonGuard} from "../../util/control/singletonHandler"; +import { getApp } from "../../appHelper.js"; +import { collectAllGroups, collectNodesByType, nodeMatchesType } from "../../util/control/collector"; +import { BVControlConfig, CONFIG_CHANGED_EVENT, readConfig, writeConfig } from "../../util/control/configHandler"; +import { findActiveControlConflicts, formatControlConflictStatus, nodesInControlGroup } from "../../util/control/controlCenterModel.js"; -const ACTIVE = "ACTIVE"; -const MUTE = "MUTE/BYPASS"; +const NODE_CLASS = "BV Control Center"; +const NORMAL = 0; +const MUTE = 2; +const BYPASS = 4; +const BASE_MODE = "bvControlBaseMode"; +const OPEN_CONTROL_RACK_EVENT = "bv-open-control-rack"; +const CONTROL_CENTER_MIN_WIDTH = 320; +const CONTROL_CENTER_WIDTH_BUFFER = 48; +const CONTROL_STATUS_WIDGET = "bv_control_conflict_status"; +const previouslyControlled = new Set(); -const comfyApp = getApp(); - -let configWatcherStarted = false; - -function startConfigWatcher() { - if (configWatcherStarted) return; - configWatcherStarted = true; - - let config = readConfig(); - if (config) { - updateControlNodes(config); - } - - setInterval(() => { - const newConfig = readConfig(); - if (!newConfig) return; - - if (JSON.stringify(newConfig) !== JSON.stringify(config)) { - config = newConfig; - updateControlNodes(config); - } - }, 500); +function restrictionForAction(action: "activate" | "bypass" | "mute") { + if (action === "activate") return { mode: NORMAL, priority: 3 }; + if (action === "mute") return { mode: MUTE, priority: 2 }; + return { mode: BYPASS, priority: 1 }; } -function hookSubgraphWidgetChanged(node: SubgraphNode, name?: string) { - let found = false - let foundNode: LGraphNode | undefined; - - node.subgraph.inputs.forEach((input) => { - input.getLinks().forEach((link) => { - if (!found) { - foundNode = getNodeHelper(link.target_id as number, node.graph) - if (!found && foundNode?.type == "BV Control Center") { - found = true - } - } - }) - }) - - if (!foundNode) { - return - } - - const config = readConfig(); - if (!config) return; - - let retries = 0; - const maxRetries = 200; - const retry = () => { - const success = updateSingleControlNode(foundNode, config, undefined, true, name); - if (success || retries >= maxRetries) { - return; - } - retries++; - setTimeout(retry, 10); +function configForValidation(config: BVControlConfig): BVControlConfig { + const available = new Set(collectAllGroups(getApp()).map((group) => group.id)); + return { + ...config, + controls: config.controls.map((control) => ({ + ...control, + assignments: control.assignments.map((assignment) => ({ ...assignment, unresolved: !available.has(assignment.groupId) })), + })), }; - - retry(); } -function updateSingleControlNode(node: any, config: BVControlConfig, savedValuesOverride?: any[], overrideValue: boolean = true, name?: string): boolean { - if (!node || !config) return false; +function nodesInGroup(item: any): any[] { + return nodesInControlGroup(item, (node: any) => nodeMatchesType(node, NODE_CLASS)); +} - let changed = false; - if (!node.widgets) node.widgets = []; +function applyConfig(config: BVControlConfig) { + for (const node of previouslyControlled) { + const base = node.properties?.[BASE_MODE]; + node.mode = config.forceActive ? NORMAL : typeof base === "number" ? base : node.mode; + if (node.properties) delete node.properties[BASE_MODE]; + node.setDirtyCanvas?.(true, true); + } + previouslyControlled.clear(); - // Prefer workflow-loaded values - // @ts-ignore - const savedValues = savedValuesOverride ?? node.widgets_values; + const groups = new Map(collectAllGroups(getApp()).map((item) => [item.id, item])); + const restrictions = new Map(); + for (const control of config.controls) { + if (!control.enabled) continue; + for (const assignment of control.assignments) { + const group = groups.get(assignment.groupId); + if (!group) continue; + for (const node of nodesInGroup(group)) { + const restriction = restrictionForAction(assignment.action); + const current = restrictions.get(node); + if (!current || restriction.priority > current.priority) restrictions.set(node, restriction); + } + } + } + for (const [node, restriction] of restrictions) { + node.properties ??= {}; + if (typeof node.properties[BASE_MODE] !== "number") node.properties[BASE_MODE] = typeof node.mode === "number" ? node.mode : NORMAL; + node.mode = restriction.mode; + previouslyControlled.add(node); + node.setDirtyCanvas?.(true, true); + } +} - const desiredCount = config.rows.length; +function removeControlPort(node: any, index: number) { + const input = node.inputs[index]; + const widget = node.widgets?.find((item: any) => item.name === input.name); + if (widget) node.removeWidget?.(widget); + if (input.link != null) node.disconnectInput(index); + node.removeInput(index); +} - - // Ensure enough inputs - while (node.inputs.length < desiredCount) { - const i = node.inputs.length; - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - const label = config.rows[i].title; - - node.addInput(inputName, "BOOLEAN", { - label, - nameLocked: true, - default: true, - widget: {name: inputName} +function syncConflictStatus(node: any, config: BVControlConfig) { + const conflicts = findActiveControlConflicts(config); + const value = formatControlConflictStatus(conflicts); + let widget = node.widgets?.find((item: any) => item.name === CONTROL_STATUS_WIDGET); + if (!widget) { + widget = node.addCustomWidget({ + name: CONTROL_STATUS_WIDGET, + type: "custom", + value, + y: 0, + options: {}, + serialize: false, + computeSize: () => [0, 24], + computeLayoutSize: () => ({ minHeight: 24, maxHeight: 24, minWidth: CONTROL_CENTER_MIN_WIDTH }), + mouse: () => false, + draw(ctx: CanvasRenderingContext2D, _node: any, width: number, y: number, height: number) { + ctx.save(); + ctx.beginPath(); + ctx.rect(10, y, Math.max(0, width - 20), height); + ctx.clip(); + ctx.fillStyle = this.__bvHasConflict ? "#ffbd66" : "#8fc9a3"; + ctx.font = "12px sans-serif"; + ctx.textBaseline = "middle"; + ctx.fillText(String(this.value ?? ""), 14, y + height / 2); + ctx.restore(); + }, }); - - const initialValue = (savedValues && savedValues[i] !== undefined) ? savedValues[i] : true; - const w = node.addWidget("toggle", inputName, initialValue, () => node.setDirtyCanvas(true), {on: ACTIVE, off: MUTE}); - w.label = label; - w.serialize = true; - - // @ts-ignore - node.inputs[i]._widget = w; - changed = true; } - - // Remove extra inputs - while (node.inputs.length > desiredCount) { - const i = node.inputs.length - 1; - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - - const wi = node.widgets?.findIndex((w: any) => w.name === inputName) ?? -1; - if (wi !== -1) node.widgets.splice(wi, 1); - - if (node.isInputConnected(i)) node.disconnectInput(i); - node.removeInput(i); - changed = true; - } - - let allSlotsRenamed = true; - // Sync labels + widget values + link input<->widget - for (let i = 0; i < desiredCount; i++) { - let label = config.rows[i].title; - - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - - if (node.inputs[i]?.label !== label) { - node.inputs[i].label = label; - changed = true; - } - - let w = node.widgets.find((ww: any) => ww.name === inputName); - if (!w) { - const initialValue = (savedValues && savedValues[i] !== undefined) ? savedValues[i] : true; - w = node.addWidget("toggle", inputName, initialValue, () => node.setDirtyCanvas(true), {on: ACTIVE, off: MUTE}); - w.serialize = true; - w.label = label; - changed = true; - } else { - if (w.options) { - if (w.options.on !== ACTIVE || w.options.off !== MUTE) { - w.options.on = ACTIVE; - w.options.off = MUTE; - changed = true; - } - } - if (overrideValue && savedValues && savedValues[i] !== undefined && w.value !== savedValues[i]) { - w.value = savedValues[i]; - w.label = label; - changed = true; - } - } - - if (node.inputs[i]) { - node.inputs[i].widget = {name: inputName}; - // @ts-ignore - node.inputs[i]._widget = w; - } - - - const ccNode = getNodeHelper(node.id, node.graph) - if(ccNode){ - const ccInput = ccNode.inputs[i] - const ccLink = ccNode.getInputLink(i) - if(ccLink && ccNode.graph){ - setTimeout(() => { - const graphNode = getOuterNode(ccNode) - if(graphNode) { - const graphWidgets = graphNode.widgets - if(graphWidgets){ - const graphWidget = graphWidgets[ccLink.origin_slot] - if(graphWidget){ - graphWidget.label = label - } - } - } - - - - }, 50) - - } - } - - const gNode = getNodeHelper(node.id, node.graph) - - if (gNode) { - let success = false - - success = renameSubGraphInputSlot(node, i, label) - - if (!success) { - allSlotsRenamed = false; - } - } else { - allSlotsRenamed = false; - } - } - - if (changed) { - node.serialize_widgets = true; - node.setDirtyCanvas(true, true); - node.size = node.computeSize?.() ?? node.size; - } - - return allSlotsRenamed; + widget.value = value; + widget.__bvHasConflict = conflicts.length > 0; + widget.tooltip = conflicts.map((conflict: any) => `${conflict.groupPath}: ${conflict.winnerAction} wins`).join("\n"); } -function updateControlNodes(config: BVControlConfig, savedValuesOverride?: any[]) { - const app = getApp(); - const graph = app.rootGraph; - if (!graph?.nodes || !config) return; +function syncNode(node: any, config: BVControlConfig) { + node.mode = NORMAL; + if (node.properties) delete node.properties[BASE_MODE]; - const controlNodes = crawlSubgraphForNode(graph.nodes); + let configureWidget = node.widgets?.find((item: any) => item.name === "configure_control_center"); + if (!configureWidget) configureWidget = node.addWidget("button", "configure_control_center", null, () => window.dispatchEvent(new Event(OPEN_CONTROL_RACK_EVENT)), { serialize: false }); + configureWidget.label = "Configure Control Center"; + configureWidget.serialize = false; - controlNodes.forEach((node) => { - let changed = false; - - if (!node.widgets) node.widgets = []; - - // @ts-ignore - const savedValues = savedValuesOverride ?? node.widgets_values; - - const commonCount = Math.min(node.inputs.length, config.rows.length); - for (let i = 0; i < commonCount; i++) { - let label = config.rows[i].title; - - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - - if (node.inputs[i].label !== label) { - node.inputs[i].label = label; - changed = true; - } - - let widget = node.widgets.find(w => w.name === inputName); - if (!widget) { - const initialValue = (savedValues && savedValues[i] !== undefined) ? savedValues[i] : true; - widget = node.addWidget("toggle", inputName, initialValue, () => node.setDirtyCanvas(true), {on: ACTIVE, off: MUTE}); - widget.serialize = true; - changed = true; - } else { - // Apply saved value on load as well - if (savedValues && savedValues[i] !== undefined && widget.value !== savedValues[i]) { - widget.value = savedValues[i]; - changed = true; - } - if (widget.options) { - if (widget.options.on !== ACTIVE || widget.options.off !== MUTE) { - widget.options.on = ACTIVE; - widget.options.off = MUTE; - changed = true; - } - } - } - - node.inputs[i].widget = {name: inputName} as any; - // @ts-ignore - node.inputs[i]._widget = widget; - - renameSubGraphInputSlot(node, i, label) - } - - if (node.inputs.length > config.rows.length) { - for (let i = node.inputs.length - 1; i >= config.rows.length; i--) { - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - const widgetIndex = node.widgets?.findIndex(w => w.name === inputName) ?? -1; - if (widgetIndex !== -1) node.widgets.splice(widgetIndex, 1); - if (node.isInputConnected(i)) node.disconnectInput(i); - node.removeInput(i); - changed = true; - } - } else if (node.inputs.length < config.rows.length) { - for (let i = node.inputs.length; i < config.rows.length; i++) { - let label = config.rows[i].title; - const inputName = `bvcc_${String(i).padStart(3, "0")}`; - - node.addInput(inputName, "BOOLEAN", { - label, - nameLocked: true, - default: true, - widget: {name: inputName} - }); - - const initialValue = (savedValues && savedValues[i] !== undefined) ? savedValues[i] : true; - const widget = node.addWidget("toggle", inputName, initialValue, () => node.setDirtyCanvas(true), {on: ACTIVE, off: MUTE}); - widget.serialize = true; - - // @ts-ignore - node.inputs[i]._widget = widget; - changed = true; - } - } - - if (changed) { - node.serialize_widgets = true; - node.setDirtyCanvas(true, true); - node.size = node.computeSize(); - } - }); -} - -function hookWidgetChanged(node: any) { - if (node.__bv_onWidgetChanged_patched) return; - node.__bv_onWidgetChanged_patched = true; - - const original = node.onWidgetChanged; - - node.onWidgetChanged = function (name: string, value: any, old_value: any, widget: IBaseWidget) { - let r; - try { - // @ts-ignore - r = original?.apply(this, arguments); - } catch { - } - - const config = readConfig(); - if (config) { - const index = node.widgets.indexOf(widget); - const title = (index !== -1 && node.inputs[index]) ? node.inputs[index].label : widget.label; - if (!title) return; - executeChange(title, value); - // updateSingleControlNode(node, config, undefined, false); - } - - return r + const desiredNames = new Set(config.controls.map((control) => `bvcc_${control.id}`)); + for (let index = node.inputs.length - 1; index >= 0; index--) { + const input = node.inputs[index]; + if (input.name.startsWith("bvcc_") && !desiredNames.has(input.name)) removeControlPort(node, index); } -} - -function normalizeGroupTitle(entry: BVControlEntry) { - return entry.ref.title -} - -function normalizeAction(value: "bypass" | "mute") { - const v = (value ?? "").toString().toLowerCase().trim(); - return v === "bypass" ? "bypass" : "mute"; -} - -function applyEntry(groupTitle: string, action: "bypass" | "mute", enabled: boolean) { - if (!groupTitle) return; - const appOrGraph = getApp() - - const a = normalizeAction(action); - - if (a === "bypass") { - if (enabled) bypassGroupsByTitle(appOrGraph, groupTitle, "overlap"); - else unbypassGroupsByTitle(appOrGraph, groupTitle, "overlap"); - return; + for (const control of config.controls) { + const name = `bvcc_${control.id}`; + const onToggle = (value: boolean) => { + const latest = readConfig(); + const next = { ...latest, controls: latest.controls.map((item) => item.id === control.id ? { ...item, enabled: Boolean(value) } : item) }; + writeConfig(next); + }; + let input = node.inputs.find((item: any) => item.name === name); + if (!input) input = node.addInput(name, "BOOLEAN", { label: control.name, nameLocked: true, widget: { name } }); + input.label = control.name; + let widget = node.widgets?.find((item: any) => item.name === name); + if (widget && !widget.__bvControlCallbackBound) { + node.removeWidget?.(widget); + widget = null; + } + if (!widget) widget = node.addWidget("toggle", name, control.enabled, onToggle, { on: "ACTIVE", off: "INACTIVE" }); + widget.__bvControlCallbackBound = true; + widget.label = control.name; + widget.value = control.enabled; + widget.serialize = true; + widget.options ??= {}; + widget.options.on = "ACTIVE"; + widget.options.off = "INACTIVE"; + widget.callback = onToggle; + input.widget = { name }; } - - if (enabled) muteGroupsByTitle(appOrGraph, groupTitle, "overlap"); - else unmuteGroupsByTitle(appOrGraph, groupTitle, "overlap"); -} - -function executeChange(title: string, value: boolean): void { - const config = readConfig(); - if (!config) return; - - const applyDisable = !value; - - const foundRow = config.rows.find(row => row.title === title); - if (!foundRow) return; - for (const entry of foundRow.entries) { - const groupTitle = normalizeGroupTitle(entry); - const action = normalizeAction(entry.action); - if (!groupTitle) continue; - - applyEntry(groupTitle, action, applyDisable); + syncConflictStatus(node, config); + const configWidget = node.widgets?.find((item: any) => item.name === "bv_control_config_json"); + if (configWidget) { + configWidget.value = JSON.stringify(configForValidation(config)); + configWidget.type = "converted-widget"; + configWidget.draw = () => {}; + configWidget.hidden = true; + configWidget.options ??= {}; + configWidget.options.hidden = true; + if (configWidget.element) configWidget.element.style.display = "none"; + configWidget.computeSize = () => [0, -4]; + configWidget.serializeValue = () => JSON.stringify(configForValidation(readConfig())); } + node.serialize_widgets = true; + const currentSize = node.size ?? [0, 0]; + const computedSize = node.computeSize?.() ?? currentSize; + const minimumWidth = Math.max(CONTROL_CENTER_MIN_WIDTH, (computedSize[0] ?? 0) + CONTROL_CENTER_WIDTH_BUFFER); + node.setSize?.([ + Math.max(currentSize[0] ?? 0, minimumWidth), + computedSize[1] ?? currentSize[1] ?? 0, + ]); + node.setDirtyCanvas?.(true, true); } -comfyApp.registerExtension({ - name: "bv_control_center", - async beforeRegisterNodeDef(nodeType, nodeData) { - const comfyClass = nodeType.type; - const nodeName = nodeData.name; +function syncAll(config = readConfig()) { + for (const { node } of collectNodesByType(getApp(), NODE_CLASS)) syncNode(node, config); + applyConfig(config); +} - const isControlCenter = - comfyClass === "BVControlCenterNode" || - nodeName === "BV Control Center" || - nodeName?.includes("BV Control Center") || - nodeName?.includes("🌀 BV Control Center"); +window.addEventListener(CONFIG_CHANGED_EVENT, (event) => syncAll((event as CustomEvent).detail)); - if (!isControlCenter) return; - - const onNodeCreated = nodeType.prototype.onNodeCreated; +getApp().registerExtension({ + name: "bv_nodepack.control_center", + async beforeRegisterNodeDef(nodeType: any, nodeData: any) { + if (nodeData.name !== NODE_CLASS) return; + const originalCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { - // @ts-ignore - const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; - this.serialize_widgets = true; - - const cfg = readConfig(); - if (cfg) { - setTimeout(() => { - - updateSingleControlNode(this, cfg); - }, 50) - } - - hookWidgetChanged(this) - startConfigWatcher(); - - return r; + const result = originalCreated?.apply(this, arguments); + syncNode(this, readConfig()); + return result; }; - - const onConfigure = nodeType.prototype.onConfigure; - nodeType.prototype.onConfigure = function (nodeInfo: any) { - // @ts-ignore - const r = onConfigure ? onConfigure.apply(this, arguments) : undefined; - this.serialize_widgets = true; - - const cfg = readConfig(); - if (cfg) { - updateSingleControlNode(this, cfg, nodeInfo?.widgets_values); - } - - return r; - }; - - - - const onConnectInput = nodeType.prototype.onConnectInput - // @ts-ignore - nodeType.prototype.onConnectInput = function (target_slot, type, output, node, slot) { - // @ts-ignore - const r = onConnectInput ? onConnectInput.apply(this, arguments) : undefined; - this.serialize_widgets = true; - - // @ts-ignore - if (node.type && !node.subgraph) { - showErrorToast("BV Control Center can only be connected to a Subgraph.") - return false - } - - if (this.type == "BV Control Center") { - ensureSubgraphContainerPatchedFromInnerNodeRetry(this, hookSubgraphWidgetChanged, executeChange); - - - if (typeof this.id !== 'number') { - return r; - } - - const id = this.id; - const retries = 20 - function retry(n = 0) { - if (n >= retries) return; - const gNode = getNodeHelper(id, getApp().rootGraph) - if (gNode) { - const suc = renameSubGraphInputSlot(gNode, target_slot) - if (suc) return; - } - setTimeout(() => { - retry(n + 1) - }, 50) - } - retry() - return r; - } - } - - const onConnectionsChange = nodeType.prototype.onConnectionsChange - // @ts-ignore - nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) { - // @ts-ignore - const r = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined; - - if (!connected && type === 1) { // 1 is LiteGraph.INPUT - if (this.type == "BV Control Center") { - - console.debug("onConnectionsChange", type, link_info, connected, index) - - const id = this.id; - - const ccNode = getNodeHelper(id as number, getApp().rootGraph, true) - - if (ccNode){ - - const sNode = getOuterNode(ccNode) as SubgraphNode - - if(link_info && sNode && sNode.widgets){ - const sWidget = sNode.widgets[link_info.origin_slot] - const value = sWidget.value - - sNode.removeWidget(sWidget) - const newSize = sNode.computeSize?.() ?? sNode.size; - sNode.setSize?.(newSize); - sNode.size = newSize; - - sNode.setDirtyCanvas?.(true, true); - sNode.graph?.setDirtyCanvas?.(true, true); - const ccINput = ccNode.getInputInfo(index) - if(ccINput != null){ - const widget = ccNode.getWidgetFromSlot(ccINput); - if (widget) { - function setWidgetValue(node: any, widgetName: any, newValue: any) { - // @ts-ignore - const w = node?.widgets?.find(x => x?.name === widgetName); - if (!w) return false; - w.value = newValue; - (w.callback || w.onChange)?.call(w, newValue, node, w); - - node.setSize?.(node.computeSize?.() ?? node.size); - - node.setDirtyCanvas?.(true, true); - node.graph?.setDirtyCanvas?.(true, true); - - return true; - } - - setWidgetValue(sNode, sWidget.name, value) - } - } - - } - - } - } - } - return r; - } - } + }, + afterConfigureGraph() { + syncAll(); + }, }); - -function patchAllSubgraphs(): number { - const graph = getApp()?.rootGraph; - if (!graph) return 0; - - const subs = findAllSubgraphContainers(graph); - - let patched = 0; - for (const s of subs) { - const didPatch = patchSubgraphContainerPrototype(s, hookSubgraphWidgetChanged, executeChange); - if (didPatch) patched++; - } - - return patched; -} - -function retryPatchAllSubgraphs(opts?: { - intervalMs?: number; - maxAttempts?: number; - stopWhenPatched?: boolean; -}) { - const intervalMs = opts?.intervalMs ?? 200; - const maxAttempts = opts?.maxAttempts ?? 30; - const stopWhenPatched = opts?.stopWhenPatched ?? true; - - let attempt = 0; - - const tick = () => { - attempt++; - - const patchedCount = patchAllSubgraphs(); - - if (stopWhenPatched && patchedCount > 0) return; - if (attempt >= maxAttempts) return; - - setTimeout(tick, intervalMs); - }; - - tick(); -} - -retryPatchAllSubgraphs({intervalMs: 150, maxAttempts: 80}); - -// Retry because LGraph might not be ready instantly -(function retry(n = 0) { - if (patchGraphAddSingletonGuard()) return; - if (n > 80) return; - setTimeout(() => retry(n + 1), 50); -})(); diff --git a/ui/src/index.css b/ui/src/index.css index 0bfa30f..0650619 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -18,16 +18,41 @@ --bv-muted-text-color: #7f8c8d; } -#bv-root { - height: 100%; +.bv-modal { + position: fixed; + inset: 0; + z-index: 2147483647; + display: grid; + place-items: center; + padding: 24px; + background: rgba(0, 0, 0, 0.6); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - box-sizing: border-box; color: var(--bv-text-color); - background: var(--bv-bg-color); } -#bv-root * { +.bv-modal * { box-sizing: border-box; } +.bv-dialog { width: min(900px, 92vw); max-height: 84vh; } +.bv-rack { width: 100%; max-height: 84vh; overflow: auto; padding: 20px; border: 1px solid var(--bv-border-color); border-radius: 14px; background: var(--comfy-menu-bg, #20242a); box-shadow: 0 18px 60px #0009; } +.bv-rack header, .bv-rack footer, .bv-control-title, .bv-assignments > div { display: flex; align-items: center; gap: 10px; } +.bv-rack header { justify-content: space-between; border-bottom: 1px solid #ffffff20; margin-bottom: 16px; } +.bv-rack h2 { margin: 0; } +.bv-rack p { margin-top: 4px; color: var(--bv-muted-text-color); } +.bv-force { display: flex; gap: 8px; padding: 10px 12px; background: #ffffff08; border-radius: 8px; } +.bv-controls { display: grid; gap: 12px; margin: 16px 0; } +.bv-control-card { border: 1px solid #ffffff20; border-radius: 10px; padding: 12px; background: #ffffff08; } +.bv-control-title input { flex: 1; font-weight: 650; } +.bv-control-title span { color: var(--bv-muted-text-color); } +.bv-assignments { display: grid; gap: 6px; margin: 10px 0; } +.bv-assignments > div span { flex: 1; } +.bv-assignments > div span small { display: block; margin-top: 3px; color: #ffbd66; } +.bv-assignments .conflict { border-left: 2px solid #ffbd66; padding-left: 8px; } +.bv-assignments .unresolved { color: #ff9f7a; } +.bv-rack input, .bv-rack select, .bv-rack button { color: inherit; background: #15181c; border: 1px solid #ffffff25; border-radius: 6px; padding: 7px 9px; } +.bv-rack button { cursor: pointer; } +.bv-rack button.primary { background: var(--bv-primary-color); border-color: transparent; } +.bv-rack footer { justify-content: flex-end; } + diff --git a/ui/src/index.tsx b/ui/src/index.tsx index fdf388c..91b4811 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -2,21 +2,20 @@ import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom/client"; import { getApp } from "./appHelper.js"; import BVPortal from "./components/BVPortal"; +import styles from "./index.css?inline"; const comfyApp = getApp(); -import "./util/control/stateHandler"; import "./components/control/bv_control_center"; -// Expose a setter so non-React callbacks (e.g., action bar button) can open the portal -let setPortalOpenExternal: ((open: boolean) => void) | null = null; +const OPEN_CONTROL_RACK_EVENT = "bv-open-control-rack"; +const STYLE_ID = "bv-nodepack-styles"; function BVRoot() { const [portalOpen, setPortalOpen] = useState(false); useEffect(() => { - setPortalOpenExternal = (open: boolean) => setPortalOpen(open); - return () => { - setPortalOpenExternal = null; - }; + const open = () => setPortalOpen(true); + window.addEventListener(OPEN_CONTROL_RACK_EVENT, open); + return () => window.removeEventListener(OPEN_CONTROL_RACK_EVENT, open); }, []); return ( @@ -32,6 +31,12 @@ function BVRoot() { const MOUNT_ID = "bv-root"; function ensureMountedOnce() { + if (!document.getElementById(STYLE_ID)) { + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = styles; + document.head.appendChild(style); + } let container = document.getElementById(MOUNT_ID); if (!container) { container = document.createElement("div"); @@ -46,20 +51,9 @@ function ensureMountedOnce() { } } -// Set up once: create an action button that opens the persistent portal comfyApp.registerExtension({ - name: "bv_nodepack.action_button", + name: "bv_nodepack.control_rack_portal", setup() { ensureMountedOnce(); }, - actionBarButtons: [ - { - icon: "pi pi-wrench", - tooltip: "BV Tools Configuration (Beta)", - onClick: () => { - ensureMountedOnce(); - setPortalOpenExternal?.(true); - }, - }, - ], }); diff --git a/ui/src/types/assets.d.ts b/ui/src/types/assets.d.ts new file mode 100644 index 0000000..e2c4cae --- /dev/null +++ b/ui/src/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module "*.css?inline" { + const content: string; + export default content; +} diff --git a/ui/src/util/control/collector.ts b/ui/src/util/control/collector.ts index 7bcc84c..61683c5 100644 --- a/ui/src/util/control/collector.ts +++ b/ui/src/util/control/collector.ts @@ -12,6 +12,8 @@ export interface CollectedGroup { group: LGraphGroup; graph: LGraph; path: GraphPathEntry[]; + id: string; + pathLabel: string; } export interface CollectedNode { @@ -89,6 +91,22 @@ function traverseGraphs( } } +function newGroupId(): string { + return globalThis.crypto?.randomUUID?.() ?? `bv-group-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function groupId(group: LGraphGroup, usedIds: Set): string { + const target = group as any; + target.flags ??= {}; + let id = typeof target.flags.bvGroupId === "string" ? target.flags.bvGroupId : ""; + if (!id || usedIds.has(id)) { + do id = newGroupId(); while (usedIds.has(id)); + target.flags.bvGroupId = id; + } + usedIds.add(id); + return id; +} + /** * Collect ALL groups in main graph + every subgraph (typed). * Pass either comfyApp or a graph. @@ -98,9 +116,12 @@ export function collectAllGroups(input: ComfyApp | LGraph | unknown): CollectedG if (!root) return []; const results: CollectedGroup[] = []; + const usedIds = new Set(); traverseGraphs(root, (graph, path) => { for (const group of getGraphGroups(graph)) { - results.push({ group, graph, path }); + const segments = path.map((entry) => String((entry.node as any).title || (entry.node as any).type || entry.node.id)); + const pathLabel = [...segments, String(group.title || "Untitled Group")].join(" / "); + results.push({ group, graph, path, id: groupId(group, usedIds), pathLabel }); } }); return results; @@ -129,7 +150,7 @@ export function collectNodesByType( return results; } -function nodeMatchesType(node: LGraphNode, typeOrPredicate: string | NodePredicate): boolean { +export function nodeMatchesType(node: LGraphNode, typeOrPredicate: string | NodePredicate): boolean { if (typeof typeOrPredicate === "function") return !!typeOrPredicate(node); const wanted = String(typeOrPredicate); diff --git a/ui/src/util/control/configHandler.ts b/ui/src/util/control/configHandler.ts index cfe3565..ca8f7fb 100644 --- a/ui/src/util/control/configHandler.ts +++ b/ui/src/util/control/configHandler.ts @@ -1,58 +1,45 @@ - import { getApp } from "../../appHelper.js"; -const comfyApp = getApp(); -const graph = comfyApp.rootGraph as any; +export type BVControlAction = "activate" | "bypass" | "mute"; -export type BVControlConfig = { - rows: BVControlRow[]; -} - -export type BVControlRow = { - title: string; - entries: BVControlEntry[] -} - -export type BVControlGroup = { - title: string; -} - -export type BVControlNode = { - id: string; -} - -// export type BVControlEntry = -// | { kind: "group"; ref: BVControlGroup } -// | { kind: "node"; ref: BVControlNode }; - -export type BVControlEntry = { - kind: "group"; - ref: BVControlGroup - action: "bypass"|"mute" +export type BVControlAssignment = { + groupId: string; + groupPath: string; + groupTitle: string; + action: BVControlAction; + unresolved?: boolean; }; -function getRootGraph(): any | null { - const app = getApp(); - return app?.rootGraph ?? null; +export type BVControl = { + id: string; + name: string; + enabled: boolean; + assignments: BVControlAssignment[]; +}; + +export type BVControlConfig = { + version: 2; + forceActive: boolean; + controls: BVControl[]; +}; + +export const CONFIG_CHANGED_EVENT = "bv-control-config-changed"; + +export function emptyConfig(): BVControlConfig { + return { version: 2, forceActive: false, controls: [] }; } -export function writeConfig(config: BVControlConfig | null) { - const graph = getRootGraph(); - if (!graph) { - console.warn("[BV] rootGraph not ready yet, cannot write config"); - return; - } - +export function writeConfig(config: BVControlConfig) { + const graph = getApp()?.rootGraph as any; + if (!graph) return; graph.extra ??= {}; graph.extra.bv_settings ??= {}; graph.extra.bv_settings.controlConfig = config; - - // optional: mark dirty so the user saving will persist it graph.setDirtyCanvas?.(true, true); + window.dispatchEvent(new CustomEvent(CONFIG_CHANGED_EVENT, { detail: config })); } -export function readConfig(): BVControlConfig | null { - const graph = getRootGraph(); - return (graph?.extra?.bv_settings?.controlConfig ?? null) as BVControlConfig | null; +export function readConfig(): BVControlConfig { + const value = (getApp()?.rootGraph as any)?.extra?.bv_settings?.controlConfig; + return value?.version === 2 ? value as BVControlConfig : emptyConfig(); } - diff --git a/ui/src/util/control/controlCenterModel.d.ts b/ui/src/util/control/controlCenterModel.d.ts new file mode 100644 index 0000000..f121c68 --- /dev/null +++ b/ui/src/util/control/controlCenterModel.d.ts @@ -0,0 +1,22 @@ +export function nodesInControlGroup( + item: any, + isExcluded?: (node: any) => boolean, +): any[]; + +export type BVControlConflictEntry = { + controlId: string; + controlName: string; + action: "activate" | "mute" | "bypass"; + groupId: string; + groupPath: string; +}; + +export type BVControlConflict = { + groupId: string; + groupPath: string; + winnerAction: "activate" | "mute" | "bypass"; + entries: BVControlConflictEntry[]; +}; + +export function findActiveControlConflicts(config: any): BVControlConflict[]; +export function formatControlConflictStatus(conflicts: BVControlConflict[]): string; diff --git a/ui/src/util/control/controlCenterModel.js b/ui/src/util/control/controlCenterModel.js new file mode 100644 index 0000000..82db4c3 --- /dev/null +++ b/ui/src/util/control/controlCenterModel.js @@ -0,0 +1,63 @@ +export function nodesInControlGroup(item, isExcluded = () => false) { + const nativeNodes = item.group.nodes ?? item.group._nodes; + if (Array.isArray(nativeNodes)) { + const resolved = nativeNodes.filter((node) => node && typeof node === "object" && !isExcluded(node)); + if (resolved.length > 0) return resolved; + } + + const [gx, gy] = item.group.pos || [0, 0]; + const [gw, gh] = item.group.size || [0, 0]; + return (item.graph._nodes ?? item.graph.nodes ?? []).filter((node) => { + if (isExcluded(node)) return false; + const [x, y] = node.pos || [0, 0]; + const [w, h] = node.size || [0, 0]; + return x + w / 2 >= gx && x + w / 2 <= gx + gw && y + h / 2 >= gy && y + h / 2 <= gy + gh; + }); +} + +const ACTION_PRIORITY = { activate: 3, mute: 2, bypass: 1 }; +const actionLabel = (action) => action.charAt(0).toUpperCase() + action.slice(1); + +export function findActiveControlConflicts(config) { + const assignmentsByGroup = new Map(); + for (const control of config.controls ?? []) { + if (!control.enabled) continue; + for (const assignment of control.assignments ?? []) { + if (assignment.unresolved) continue; + const entries = assignmentsByGroup.get(assignment.groupId) ?? []; + entries.push({ + controlId: control.id, + controlName: control.name, + action: assignment.action, + groupId: assignment.groupId, + groupPath: assignment.groupPath || assignment.groupTitle || assignment.groupId, + }); + assignmentsByGroup.set(assignment.groupId, entries); + } + } + + const conflicts = []; + for (const entries of assignmentsByGroup.values()) { + if (new Set(entries.map((entry) => entry.action)).size < 2) continue; + const winnerAction = entries.reduce((winner, entry) => + ACTION_PRIORITY[entry.action] > ACTION_PRIORITY[winner] ? entry.action : winner, + entries[0].action); + conflicts.push({ + groupId: entries[0].groupId, + groupPath: entries[0].groupPath, + winnerAction, + entries, + }); + } + return conflicts; +} + +export function formatControlConflictStatus(conflicts) { + if (!conflicts.length) return "✓ No active conflicts"; + if (conflicts.length > 1) return `⚠ ${conflicts.length} active control conflicts`; + const conflict = conflicts[0]; + const overridden = [...new Set(conflict.entries + .filter((entry) => entry.action !== conflict.winnerAction) + .map((entry) => entry.action))].join(", "); + return `⚠ ${conflict.groupPath}: ${actionLabel(conflict.winnerAction)} overrides ${overridden.split(", ").map(actionLabel).join(", ")}`; +} diff --git a/ui/src/util/control/controlHelper.ts b/ui/src/util/control/controlHelper.ts deleted file mode 100644 index e119aa0..0000000 --- a/ui/src/util/control/controlHelper.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { getApp } from "../../appHelper.js"; -import { LGraphNode } from "../../types/comfyui-frontend-types.augment"; - -export function showErrorToast(msg: string) { - getApp().extensionManager.toast.add({ - severity: "error", - summary: "Error", - detail: msg, - life: 5000 - }); -} - -export function getNodeHelper(nodeId: number, graph: any, subgraph: boolean = false): LGraphNode | undefined { - if (!graph?.nodes) return; - - let foundNode: LGraphNode | undefined; - - graph.nodes.forEach((node: any) => { - if (!foundNode) { - if (node.id == nodeId) { - if (subgraph) { - foundNode = node; - if (node.subgraph && !node.type) { - foundNode = node; - } - } else { - if (!node.subgraph) { - foundNode = node; - } - } - } - if (!foundNode && node.isSubgraphNode) { - foundNode = getNodeHelper(nodeId, node.subgraph) - } - } - }); - - return foundNode; -} - -export function crawlSubgraphForNode(nodes: any[]): LGraphNode[] { - const controlNodes: LGraphNode[] = []; - nodes.forEach((node: any) => { - if (node.isSubgraphNode && node.isSubgraphNode()) { - const sGraph = node; - controlNodes.push(...crawlSubgraphForNode(sGraph.subgraph.nodes)) - } else { - if (node.type === "BV Control Center") { - controlNodes.push(node); - } - } - }) - return controlNodes; -} - -export function getWidgetValue(node: LGraphNode, slot: number) { - const inputSlot = node.getInputInfo(slot) - if (!inputSlot) return; - return node.getWidgetFromSlot(inputSlot)?.value as boolean; -} - - diff --git a/ui/src/util/control/groupWatcher.ts b/ui/src/util/control/groupWatcher.ts deleted file mode 100644 index e69de29..0000000 diff --git a/ui/src/util/control/singletonHandler.ts b/ui/src/util/control/singletonHandler.ts deleted file mode 100644 index 7df527d..0000000 --- a/ui/src/util/control/singletonHandler.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getApp } from "../../appHelper.js"; -import { showErrorToast } from "./controlHelper"; - -export const BV_SINGLETON_TYPE = "BV Control Center"; - -let singletonRemovalScheduled = false; -const pendingSingletonAdds: Array<{ node: any; graph: any }> = []; - -export function scheduleRemoveJustAddedSingletons() { - if (singletonRemovalScheduled) return; - singletonRemovalScheduled = true; - - // next tick: let ComfyUI finish subgraph conversion / internal moves - setTimeout(() => { - singletonRemovalScheduled = false; - - const root = getApp()?.rootGraph; - if (!root) return; - - // collect all singleton nodes across entire workflow (root + subgraphs) - const all: Array<{ node: any; graph: any }> = []; - const walk = (g: any) => { - for (const n of g?.nodes ?? []) { - if (!n) continue; - if (n.type === BV_SINGLETON_TYPE) all.push({node: n, graph: g}); - if (n.isSubgraphNode?.() && n.subgraph) walk(n.subgraph); - } - }; - walk(root); - - if (all.length <= 1) { - pendingSingletonAdds.length = 0; - return; - } - - // Prefer keeping an "old" one: keep the one with the smallest id (usually the existing one) - all.sort((a, b) => (a.node?.id ?? 0) - (b.node?.id ?? 0)); - const keep = all[0].node; - - // Remove everything else, but especially remove the ones that were just added by the user - let removedAny = false; - - for (const entry of all.slice(1)) { - const n = entry.node; - const g = entry.graph; - - if (n === keep) continue; - - try { - g.remove(n); - } catch { - const idx = g.nodes?.indexOf(n); - if (idx >= 0) g.nodes.splice(idx, 1); - } - - removedAny = true; - } - - if (removedAny) { - showErrorToast("BV Control Center is already in the workflow."); - root.setDirtyCanvas?.(true, true); - } - - pendingSingletonAdds.length = 0; - }, 0); -} - -export function patchGraphAddSingletonGuard() { - const LGraphCtor: any = (window as any).LGraph; - if (!LGraphCtor?.prototype) return false; - - const proto = LGraphCtor.prototype; - if (proto.__bv_singleton_add_patched) return true; - proto.__bv_singleton_add_patched = true; - - const addFnName = proto.add ? "add" : (proto.addNode ? "addNode" : null); - if (!addFnName) { - return false; - } - - const origAdd = proto[addFnName]; - - proto[addFnName] = function (node: any) { - const r = origAdd.apply(this, arguments); - - if (node?.type === BV_SINGLETON_TYPE) { - pendingSingletonAdds.push({node, graph: this}); - scheduleRemoveJustAddedSingletons(); - } - - return r; - }; - - return true; -} diff --git a/ui/src/util/control/stateHandler.ts b/ui/src/util/control/stateHandler.ts deleted file mode 100644 index 478230a..0000000 --- a/ui/src/util/control/stateHandler.ts +++ /dev/null @@ -1,351 +0,0 @@ -// @ts-ignore -// import type { ComfyApp, LGraph, LGraphGroup, LGraphNode } from "@comfyorg/comfyui-frontend-types"; - - -import type { ComfyApp, LGraph, LGraphGroup, LGraphNode } from "../../types/comfyui-frontend-types.augment"; - -/** LiteGraph node modes (typical) */ -export enum NodeMode { - ALWAYS = 0, - ON_EVENT = 1, - NEVER = 2, - ON_TRIGGER = 3, -} - -const COMFY_MODE_NORMAL = 0; -const COMFY_MODE_BYPASS = 4; -const COMFY_MODE_MUTE = 2; - -/** Accept either app or graph */ -function resolveRootGraph(input: ComfyApp | LGraph | unknown): LGraph | null { - if (!input || typeof input !== "object") return null; - const x = input as any; - - // prefer rootGraph (your case) - if (x.rootGraph && typeof x.rootGraph === "object") return x.rootGraph as LGraph; - if (x.graph && typeof x.graph === "object") return x.graph as LGraph; - - // treat input itself as a graph - return input as LGraph; -} - -function getGraphNodes(graph: LGraph): LGraphNode[] { - return (graph._nodes ?? graph.nodes ?? []) as LGraphNode[]; -} - -function getGraphGroups(graph: LGraph): LGraphGroup[] { - return (graph._groups ?? graph.groups ?? []) as LGraphGroup[]; -} - -function getNodeSubgraph(node: LGraphNode): LGraph | null { - if(node.isSubgraphNode()) return node.subgraph as LGraph; - return null; -} - -function traverseGraphs(root: LGraph, cb: (graph: LGraph) => void): void { - const visited = new Set(); - const stack: LGraph[] = [root]; - - while (stack.length) { - const g = stack.pop()!; - const key = g as unknown as object; - if (visited.has(key)) continue; - visited.add(key); - - cb(g); - - for (const n of getGraphNodes(g)) { - const sg = getNodeSubgraph(n); - if (sg) stack.push(sg); - } - } -} - -/** -------- Node lookup helpers -------- */ - -export function findNodeById(input: ComfyApp | LGraph | unknown, id: number | string): { graph: LGraph; node: LGraphNode } | null { - const root = resolveRootGraph(input); - if (!root) return null; - - let found: { graph: LGraph; node: LGraphNode } | null = null; - - traverseGraphs(root, (graph) => { - if (found) return; - const nodes = getGraphNodes(graph); - for (const node of nodes) { - if (String(node.id) === String(id)) { - found = { graph, node }; - return; - } - } - }); - - return found; -} - -/** -------- Mute / Unmute nodes by ID -------- - * We store previous mode so unmute can restore it. - * Stored on node.properties.__bv_prev_mode (safe and serializable in most builds). - */ -const BV_PREV_MODE_KEY = "__bv_prev_mode"; - -export function muteNodeById(input: ComfyApp | LGraph | unknown, id: number | string): boolean { - const hit = findNodeById(input, id); - if (!hit) return false; - - const node = hit.node as any; - node.properties ??= {}; - if (typeof node.properties[BV_PREV_MODE_KEY] !== "number") { - node.properties[BV_PREV_MODE_KEY] = typeof node.mode === "number" ? node.mode : NodeMode.ALWAYS; - } - node.mode = NodeMode.NEVER; - return true; -} - -export function unmuteNodeById(input: ComfyApp | LGraph | unknown, id: number | string): boolean { - const hit = findNodeById(input, id); - if (!hit) return false; - - const node = hit.node as any; - const prev = node.properties?.[BV_PREV_MODE_KEY]; - node.mode = typeof prev === "number" ? prev : NodeMode.ALWAYS; - - // optional: cleanup - if (node.properties && BV_PREV_MODE_KEY in node.properties) delete node.properties[BV_PREV_MODE_KEY]; - - return true; -} - -/** -------- Bypass (node-level) -------- - * IMPORTANT: ComfyUI bypass implementation can differ across builds. - * This is a pragmatic approach: - * - set mode to NEVER - * - store a flag that your UI/logic can interpret as "bypass" - * - * If your build has a dedicated field (e.g. node.bypass = true or node.mode = ), - * adapt ONLY inside this function. - */ -const BV_BYPASS_KEY = "__bv_bypass"; -const BV_PREV_MODE_FOR_BYPASS_KEY = "__bv_prev_mode_for_bypass"; - -export function bypassNodeById(input: ComfyApp | LGraph | unknown, id: number | string): boolean { - const hit = findNodeById(input, id); - if (!hit) return false; - - const node = hit.node as any; - node.properties ??= {}; - - if (typeof node.properties[BV_PREV_MODE_FOR_BYPASS_KEY] !== "number") { - node.properties[BV_PREV_MODE_FOR_BYPASS_KEY] = typeof node.mode === "number" ? node.mode : NodeMode.ALWAYS; - } - - node.properties[BV_BYPASS_KEY] = true; - - // Common fallback: mark as never-run; server/runner may treat bypass specially depending on build - node.mode = NodeMode.NEVER; - - return true; -} - -export function unbypassNodeById(input: ComfyApp | LGraph | unknown, id: number | string): boolean { - const hit = findNodeById(input, id); - if (!hit) return false; - - const node = hit.node as any; - const prev = node.properties?.[BV_PREV_MODE_FOR_BYPASS_KEY]; - node.mode = typeof prev === "number" ? prev : NodeMode.ALWAYS; - - if (node.properties) { - delete node.properties[BV_BYPASS_KEY]; - delete node.properties[BV_PREV_MODE_FOR_BYPASS_KEY]; - } - - return true; -} - -/** -------- Group -> Nodes in group (canvas box) -------- */ - -export type GroupNodeContainment = "topleft" | "fully" | "overlap"; - -/** Safely get node size */ -function nodeSize(node: LGraphNode): [number, number] { - const s = (node as any).size; - if (Array.isArray(s) && s.length >= 2) return [Number(s[0]) || 0, Number(s[1]) || 0]; - return [0, 0]; -} - -/** Check if a node is inside a group box */ -export function isNodeInGroup(node: LGraphNode, group: LGraphGroup, mode: GroupNodeContainment = "overlap"): boolean { - const gpos = (group.pos ?? [0, 0]) as [number, number]; - const gsize = (group.size ?? [0, 0]) as [number, number]; - const npos = (node.pos ?? [0, 0]) as [number, number]; - const nsize = nodeSize(node); - - const gx1 = gpos[0], gy1 = gpos[1]; - const gx2 = gpos[0] + gsize[0], gy2 = gpos[1] + gsize[1]; - - const nx1 = npos[0], ny1 = npos[1]; - const nx2 = npos[0] + nsize[0], ny2 = npos[1] + nsize[1]; - - if (mode === "topleft") { - return nx1 >= gx1 && nx1 <= gx2 && ny1 >= gy1 && ny1 <= gy2; - } - - if (mode === "fully") { - return nx1 >= gx1 && ny1 >= gy1 && nx2 <= gx2 && ny2 <= gy2; - } - - // overlap (default): any intersection - const overlapX = nx1 <= gx2 && nx2 >= gx1; - const overlapY = ny1 <= gy2 && ny2 >= gy1; - return overlapX && overlapY; -} - -/** Find all groups by title across root graph + subgraphs */ -export function findGroupsByTitle(input: ComfyApp | LGraph | unknown, title: string): Array<{ graph: LGraph; group: LGraphGroup }> { - const root = resolveRootGraph(input); - if (!root) return []; - - const matches: Array<{ graph: LGraph; group: LGraphGroup }> = []; - const wanted = title.trim(); - - traverseGraphs(root, (graph) => { - for (const group of getGraphGroups(graph)) { - if ((group.title ?? "").trim() === wanted) { - matches.push({ graph, group }); - } - } - }); - - return matches; -} - -/** Collect nodes inside a specific group (in the SAME graph) */ -export function collectNodesInGroup(graph: LGraph, group: LGraphGroup, containment: GroupNodeContainment = "overlap"): LGraphNode[] { - const nodes = getGraphNodes(graph); - return nodes.filter((n) => isNodeInGroup(n, group, containment)); -} - -/** -------- Group actions (by title) -------- - * If multiple groups share the same title, ALL will be affected. - * Return value contains details so you can show a UI summary. - */ -export interface GroupActionResult { - groupTitle: string; - affectedGroups: number; - affectedNodes: number; - perGroup: Array<{ - graph: LGraph; - group: LGraphGroup; - nodes: LGraphNode[]; - }>; -} - -function buildGroupResult( - input: ComfyApp | LGraph | unknown, - title: string, - containment: GroupNodeContainment -): GroupActionResult { - const matches = findGroupsByTitle(input, title); - - const perGroup = matches.map(({ graph, group }) => ({ - graph, - group, - nodes: collectNodesInGroup(graph, group, containment), - })); - - const affectedNodes = perGroup.reduce((sum, x) => sum + x.nodes.length, 0); - - return { - groupTitle: title, - affectedGroups: perGroup.length, - affectedNodes, - perGroup, - }; -} - -export function muteGroupsByTitle( - input: ComfyApp | LGraph | unknown, - title: string, - containment: GroupNodeContainment = "overlap" -): GroupActionResult { - const res = buildGroupResult(input, title, containment); - - for (const item of res.perGroup) { - for (const node of item.nodes) { - // mute by id if present, else directly - const n: any = node; - n.properties ??= {}; - if (typeof n.properties[BV_PREV_MODE_KEY] !== "number") { - n.properties[BV_PREV_MODE_KEY] = typeof n.mode === "number" ? n.mode : NodeMode.ALWAYS; - } - n.mode = COMFY_MODE_MUTE; - } - } - - return res; -} - -export function unmuteGroupsByTitle( - input: ComfyApp | LGraph | unknown, - title: string, - containment: GroupNodeContainment = "overlap" -): GroupActionResult { - const res = buildGroupResult(input, title, containment); - - for (const item of res.perGroup) { - for (const node of item.nodes) { - const n: any = node; - const prev = n.properties?.[BV_PREV_MODE_KEY]; - n.mode = typeof prev === "number" ? prev : COMFY_MODE_NORMAL; - if (n.properties && BV_PREV_MODE_KEY in n.properties) delete n.properties[BV_PREV_MODE_KEY]; - } - } - - return res; -} - -export function bypassGroupsByTitle( - input: ComfyApp | LGraph | unknown, - title: string, - containment: GroupNodeContainment = "overlap" -): GroupActionResult { - const res = buildGroupResult(input, title, containment); - - for (const item of res.perGroup) { - for (const node of item.nodes) { - const n: any = node; - n.properties ??= {}; - if (typeof n.properties[BV_PREV_MODE_FOR_BYPASS_KEY] !== "number") { - n.properties[BV_PREV_MODE_FOR_BYPASS_KEY] = typeof n.mode === "number" ? n.mode : NodeMode.ALWAYS; - } - n.properties[BV_BYPASS_KEY] = true; - n.mode = COMFY_MODE_BYPASS; - } - } - - return res; -} - -export function unbypassGroupsByTitle( - input: ComfyApp | LGraph | unknown, - title: string, - containment: GroupNodeContainment = "overlap" -): GroupActionResult { - const res = buildGroupResult(input, title, containment); - - for (const item of res.perGroup) { - for (const node of item.nodes) { - const n: any = node; - const prev = n.properties?.[BV_PREV_MODE_FOR_BYPASS_KEY]; - n.mode = typeof prev === "number" ? prev : COMFY_MODE_NORMAL; - - if (n.properties) { - delete n.properties[BV_BYPASS_KEY]; - delete n.properties[BV_PREV_MODE_FOR_BYPASS_KEY]; - } - } - } - - return res; -} diff --git a/ui/src/util/control/subgraphHandler.ts b/ui/src/util/control/subgraphHandler.ts deleted file mode 100644 index e0840f6..0000000 --- a/ui/src/util/control/subgraphHandler.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { getApp } from "../../appHelper.js"; -import { - ExposedWidget, IBaseWidget, - LGraphNode, - Subgraph, - SubgraphInput, - SubgraphNode -} from "../../types/comfyui-frontend-types.augment"; -import {getNodeHelper, getWidgetValue} from "./controlHelper"; -import { readConfig } from "./configHandler"; - -const ACTIVE = "( # ACTIVE # )"; -const MUTE = "(MUTE/BYPASS)"; - -export function renameSubGraphInputSlot(node: LGraphNode, slotNumber: number, slotName?: string) { - - let label = slotName - if (!slotName) { - // @ts-ignore - label = node.inputs[slotNumber].label - if (!label) label = node.inputs[slotNumber].name - } - - if (!label) return false; - - try { - if (node.getInputLink(slotNumber)) { - if (node.graph?._subgraphs) { - const subgraph = node.graph as Subgraph - - const link = node.getInputLink(slotNumber); - const orgingId = link?.origin_id - const orgingSlot = link?.origin_slot; - if (subgraph.inputNode.id == orgingId && typeof orgingSlot === 'number') { - const slot = subgraph.inputNode.allSlots[orgingSlot] - subgraph.renameInput(slot, label) - // subgraph.renameInput(slot, label.split(" - ")[0]) - return true; - } - return false - } - return true; - } - } catch (e) { - console.error("Error renaming subgraph input slot (LINK)", e) - return false; - } - return false - -} - -export function findAllSubgraphContainers(graph: any): any[] { - const result: any[] = []; - const walk = (nodes: any[]) => { - for (const n of nodes ?? []) { - if (n?.isSubgraphNode?.()) { - result.push(n); - if (n.subgraph?.nodes) walk(n.subgraph.nodes); - } - } - }; - walk(graph?.nodes); - return result; -} - -export function getInnerNodeFromOuterNode( - outerNode: LGraphNode, - match?: ((n: LGraphNode) => boolean) | string -): LGraphNode | undefined { - if (!outerNode?.isSubgraphNode?.()) return undefined; - - const innerGraph: any = (outerNode as any).subgraph; - if (!innerGraph) return undefined; - - const nodes: LGraphNode[] = innerGraph._nodes ?? innerGraph.nodes ?? []; - - const predicate = - typeof match === "function" - ? match - : typeof match === "string" - ? (n: any) => n?.type === match || n?.comfyClass === match || n?.title === match - : () => true; // first node (fallback) - - return nodes.find(predicate); -} - -export function getOuterNode(innerNode: LGraphNode) { - if (innerNode.graph?.subgraphs) { - const subgraph = innerNode.graph as Subgraph; - - function walk(graph: any, type: string): LGraphNode | undefined { - let foundNode: LGraphNode | undefined; - for (const node of graph.nodes) { - if (!foundNode) { - if (node.isSubgraphNode()) { - if (type == node.type) { - return node; - } else { - foundNode = walk(node.subgraph, type) - if (foundNode) return foundNode; - } - } - } else { - return foundNode; - } - } - return foundNode; - } - return walk(getApp().rootGraph, subgraph.id) - } - return undefined; -} - -export function patchSubgraphContainerPrototype(subgraphNode: any, hookSubgraphWidgetChanged: (node: SubgraphNode) => void, executeChange: (title: string, value: boolean) => void) { - const proto = subgraphNode?.constructor?.prototype; - - if (!proto) return false; - if (proto.__bv_patched) return false; - - proto.__bv_patched = true; - - // Preserve original if it exists - const original = proto.onWidgetChanged; - - proto.onWidgetChanged = function ( - name: string, - value: any, - old_value: any, - widget: any - ) { - let r; - try { - // @ts-ignore - r = original?.apply(this, arguments); - } catch { - } - - const graph = getApp()?.rootGraph; - const node = getNodeHelper(this.id, graph, true) as SubgraphNode - - const sWidget = widget as IBaseWidget - const iNode = getInnerNodeFromOuterNode(node, "BV Control Center") - if (node && iNode) { - console.debug("Subgraph container widget changed", name, value, old_value, widget, node, widget.label) - // hookSubgraphWidgetChanged(node, name) - // widget.label = widget.label.split(" - ")[0] + " - " + (value ? ACTIVE : MUTE) - - iNode.widgets?.forEach((w) => { - if(w.name == sWidget.name){ - w.label = widget.label - } - }) - } - - if (widget?.label) { - executeChange(widget.label, value); - } - - return r - }; - - return true; -} - -export function ensureSubgraphContainerPatchedFromInnerNode(innerNode: any, hookSubgraphWidgetChanged: (node: SubgraphNode) => void, executeChange: (title: string, value: boolean) => void) { - const g: any = innerNode?.graph; - if (!g) return false; - - const container = getOuterNode(innerNode) - if (!container) return false; - - return patchSubgraphContainerPrototype(container, hookSubgraphWidgetChanged, executeChange); -} - -export function ensureSubgraphContainerPatchedFromInnerNodeRetry(innerNode: any, hookSubgraphWidgetChanged: (node: SubgraphNode) => void, executeChange: (title: string, value: boolean) => void, maxTries = 20) { - let tries = 0; - const tick = () => { - console.debug("Retrying patching subgraph container from inner node", innerNode.id, tries) - tries++; - if (ensureSubgraphContainerPatchedFromInnerNode(innerNode, hookSubgraphWidgetChanged, executeChange)) return; - if (tries >= maxTries) return; - requestAnimationFrame(tick); - }; - tick(); -}
User-defined workflow states