Polish regional editor and publish v0.3.2 assets

This commit is contained in:
BlackVortexAI
2026-08-17 15:33:29 +02:00
parent 3db83e0f59
commit e5377e40e2
44 changed files with 2619 additions and 666 deletions
+380 -628
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -1,5 +1,10 @@
# Third-party notices
This file records third-party code or data incorporated into BV Node Pack and the
licenses or provenance obligations that accompany it. Projects that influenced
design or research without contributing copied code are acknowledged separately
below and in the README.
## Baseline completion tag data
`data/completion/bv_default_tags.csv` was sourced from the
@@ -37,3 +42,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Inspiration and research acknowledgements
The following projects informed product design, interoperability research or
architecture discussions. They are not bundled runtime dependencies, and no copied
implementation is claimed unless another section of this file says otherwise.
- [`rgthree-comfy`](https://github.com/rgthree/rgthree-comfy): compact workflow,
control and seed interaction patterns.
- [`ComfyUI_agilly1989_motorway`](https://github.com/agilly1989/ComfyUI_agilly1989_motorway):
the original inspiration for configurable, user-named values carried and
overridden along BV Pipe chains.
- [`cg-use-everywhere`](https://github.com/chrisgoringe/cg-use-everywhere):
wireless value broadcasting and routing-control concepts that informed the
Smart Pipe wireless design.
- [`ComfyUI-KJNodes`](https://github.com/kijai/ComfyUI-KJNodes): the Ideogram
prompt-builder editor and detached authoring workflow.
- [`ComfyUI_LC123_nodes`](https://github.com/lonecatone23/ComfyUI_LC123_nodes):
painted regional authoring plus Krea 2 and Anima workflow references.
- [`RES4LYF`](https://github.com/ClownsharkBatwing/RES4LYF): clean-room research
reference for regional attention, overlap semantics and backend boundaries.
- [`comfy-ex-tagcomplete`](https://github.com/jupo-ai/comfy-ex-tagcomplete):
completion interoperability and UX research. Its bundled tag-data relationship
is documented in the first section of this file.
+64
View File
@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="640" viewBox="0 0 1600 640" role="img" aria-labelledby="title desc">
<title id="title">BV Node Pack for ComfyUI</title>
<desc id="desc">A dark ComfyUI-inspired workflow showing regional prompting, structured prompts, smart pipes and subgraph controls.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b0f14"/><stop offset="1" stop-color="#151c26"/></linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0"><stop stop-color="#269ee8"/><stop offset="1" stop-color="#7667f5"/></linearGradient>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M32 0H0V32" fill="none" stroke="#ffffff" stroke-opacity=".045"/></pattern>
<filter id="shadow"><feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000" flood-opacity=".55"/></filter>
</defs>
<rect width="1600" height="640" rx="34" fill="url(#bg)"/>
<rect width="1600" height="640" rx="34" fill="url(#grid)"/>
<circle cx="1360" cy="80" r="260" fill="#269ee8" opacity=".08"/>
<circle cx="180" cy="590" r="300" fill="#7667f5" opacity=".08"/>
<g font-family="Inter,Segoe UI,Arial,sans-serif">
<g transform="translate(90 92)">
<circle cx="34" cy="34" r="34" fill="url(#accent)"/>
<path d="M19 35c8-18 26-18 31-5 5 14-13 27-25 17-9-7-3-23 11-21 9 2 9 13 1 15" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round"/>
<text x="92" y="34" fill="#fff" font-size="48" font-weight="750">BV Node Pack</text>
<text x="94" y="70" fill="#a9b8ca" font-size="22">Deep workflow tools for ComfyUI</text>
</g>
<g filter="url(#shadow)">
<g transform="translate(92 240)">
<rect width="315" height="230" rx="14" fill="#222831" stroke="#4c78a8" stroke-width="2"/>
<rect width="315" height="48" rx="14" fill="#2b3440"/><rect y="36" width="315" height="12" fill="#2b3440"/>
<circle cx="25" cy="24" r="8" fill="#4c78a8"/><text x="45" y="31" fill="#fff" font-size="20" font-weight="650">Regional Prompt</text>
<text x="24" y="84" fill="#93a4b7" font-size="15">GLOBAL</text><rect x="24" y="98" width="267" height="35" rx="6" fill="#151a20"/>
<text x="24" y="164" fill="#93a4b7" font-size="15">REGIONS</text>
<rect x="24" y="178" width="118" height="28" rx="6" fill="#e45756" opacity=".75"/><rect x="154" y="178" width="137" height="28" rx="6" fill="#4c78a8" opacity=".85"/>
<circle cx="315" cy="82" r="7" fill="#64dc85"/>
</g>
<g transform="translate(500 208)">
<rect width="600" height="300" rx="16" fill="#181d24" stroke="#394555" stroke-width="2"/>
<rect width="600" height="48" rx="16" fill="#232a33"/><rect y="36" width="600" height="12" fill="#232a33"/>
<text x="24" y="31" fill="#fff" font-size="19" font-weight="650">BV Regional Editor</text>
<rect x="22" y="68" width="140" height="204" rx="8" fill="#101419" stroke="#303946"/>
<text x="38" y="98" fill="#b8c5d3" font-size="14">LAYERS</text>
<rect x="38" y="116" width="108" height="30" rx="5" fill="#e45756" opacity=".72"/><rect x="38" y="156" width="108" height="30" rx="5" fill="#4c78a8" opacity=".8"/>
<rect x="182" y="68" width="270" height="204" rx="8" fill="#0c1015" stroke="#303946"/>
<rect x="214" y="94" width="206" height="152" rx="4" fill="#252c34"/>
<rect x="228" y="109" width="90" height="122" fill="#e45756" opacity=".55"/><rect x="304" y="120" width="102" height="111" fill="#4c78a8" opacity=".62"/>
<rect x="472" y="68" width="106" height="204" rx="8" fill="#101419" stroke="#303946"/>
<text x="486" y="98" fill="#b8c5d3" font-size="14">PROMPT</text>
<rect x="486" y="114" width="78" height="62" rx="5" fill="#191f27"/><rect x="486" y="186" width="78" height="62" rx="5" fill="#191f27"/>
</g>
<g transform="translate(1195 250)">
<rect width="310" height="188" rx="14" fill="#222831" stroke="#7667f5" stroke-width="2"/>
<rect width="310" height="46" rx="14" fill="#2b3440"/><rect y="34" width="310" height="12" fill="#2b3440"/>
<circle cx="24" cy="23" r="8" fill="#7667f5"/><text x="44" y="30" fill="#fff" font-size="19" font-weight="650">Anima Conditioning</text>
<text x="22" y="83" fill="#9faec0" font-size="14">MODEL</text><circle cx="0" cy="78" r="7" fill="#b58bd8"/>
<text x="22" y="117" fill="#9faec0" font-size="14">CLIP</text><circle cx="0" cy="112" r="7" fill="#f4df45"/>
<text x="22" y="151" fill="#9faec0" font-size="14">BV_REGIONAL</text><circle cx="0" cy="146" r="7" fill="#64dc85"/>
<circle cx="310" cy="82" r="7" fill="#b58bd8"/><circle cx="310" cy="116" r="7" fill="#f4a340"/><circle cx="310" cy="150" r="7" fill="#f4a340"/>
</g>
</g>
<path d="M407 322C450 322 455 350 500 350" fill="none" stroke="#64dc85" stroke-width="5" opacity=".9"/>
<path d="M1100 358C1142 358 1152 396 1195 396" fill="none" stroke="#64dc85" stroke-width="5" opacity=".9"/>
<text x="800" y="574" text-anchor="middle" fill="#d9e4ef" font-size="20">Regional prompting · Prompt AST · Smart Pipes · Subgraph UI · Local autocomplete</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+39
View File
@@ -0,0 +1,39 @@
# Regional documentation shot list
This file tracks the current documentation screenshots and optional future additions.
Capture at 100% browser scale with unrelated panels closed and no personal paths,
usernames, seeds or model filenames visible unless they are intentional.
## Present assets
- `editor-workspace.png`, `editor-floating.png` — both editor layouts.
- `view-controls.png` — mask and background-image display opacity.
- `regional-prompt-node.png`, `toolbar-*.png`, `quick-edit.png` — editor entry points.
- `region-*.png` — main rectangles, interaction rectangles and painted light arc.
- `autocomplete-*.png` — Quick Edit, full editor and native multiline completion.
- `completion-settings.png` — dataset selection, priority and global enable switch.
- `anima-workflow.png` — minimal Anima regional execution graph.
- `../../../examples/images/anima-android-dance-regional-showcase.png` — final
metadata-bearing workflow PNG and reference result.
## Still needed
1. `minimal-native-workflow.png`
- Regional Prompt → Native Conditioning → standard KSampler.
2. `editor-tools.png`
- One rectangle, one additive brush and a subtractive split; show selection handles.
3. `image-feedback.png`
- Preview/Save Send target selector plus the resulting editor background.
4. `document-tools.png`
- Deconstructor, Prompt Extract and Mask Render with representative outputs.
5. `completion-priority.png`
- At least two datasets so the ordering controls and priority rule are visible.
## Replacement quality bar
- Prefer 16:9 or a tight landscape crop.
- Use English UI labels.
- Avoid open menus unless the menu itself is the subject.
- Show one concept per image.
- Ensure cables do not obscure node titles or controls.
- Use the same dark ComfyUI theme and zoom level throughout a sequence.
Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+108
View File
@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1680" height="720" viewBox="0 0 1680 720" role="img" aria-labelledby="title desc">
<title id="title">BV Node Pack for ComfyUI</title>
<desc id="desc">A dark 21:9 banner presenting BV Node Pack and its regional prompting editor.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#090d12"/>
<stop offset=".55" stop-color="#111822"/>
<stop offset="1" stop-color="#182334"/>
</linearGradient>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#2ca7ee"/>
<stop offset="1" stop-color="#7567f5"/>
</linearGradient>
<linearGradient id="cyan" x1="0" y1="0" x2="1" y2="0">
<stop stop-color="#46b6ff"/>
<stop offset="1" stop-color="#4c78a8"/>
</linearGradient>
<pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse">
<path d="M36 0H0V36" fill="none" stroke="#fff" stroke-opacity=".045"/>
</pattern>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000" flood-opacity=".6"/>
</filter>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="28"/>
</filter>
<clipPath id="editorClip"><rect x="790" y="100" width="800" height="520" rx="24"/></clipPath>
</defs>
<rect width="1680" height="720" fill="url(#bg)"/>
<rect width="1680" height="720" fill="url(#grid)"/>
<circle cx="250" cy="630" r="340" fill="#7567f5" opacity=".09" filter="url(#glow)"/>
<circle cx="1510" cy="60" r="300" fill="#2ca7ee" opacity=".12" filter="url(#glow)"/>
<g font-family="Inter,Segoe UI,Arial,sans-serif">
<g transform="translate(92 110)">
<circle cx="46" cy="46" r="46" fill="url(#brand)"/>
<path d="M25 48c10-25 36-27 45-9 9 20-17 39-35 24-13-11-4-33 16-29 13 3 13 19 2 23" fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round"/>
<text x="120" y="47" fill="#fff" font-size="58" font-weight="760">BV Node Pack</text>
<text x="122" y="88" fill="#aab9ca" font-size="24" letter-spacing=".3">Advanced workflow tools for ComfyUI</text>
</g>
<g transform="translate(94 280)">
<text fill="#fff" font-size="43" font-weight="700">
<tspan x="0" y="0">Design prompts spatially.</tspan>
<tspan x="0" y="56">Keep workflows modular.</tspan>
</text>
<text x="2" y="152" fill="#aebdcd" font-size="24">Regional prompting, structured prompts,</text>
<text x="2" y="188" fill="#aebdcd" font-size="24">Smart Pipes and subgraph-aware controls.</text>
<g transform="translate(0 244)" font-size="19" font-weight="650">
<rect width="190" height="50" rx="25" fill="#1c2c3b" stroke="#2ca7ee" stroke-opacity=".7"/>
<text x="95" y="32" text-anchor="middle" fill="#c8eaff">Regional Editor</text>
<rect x="208" width="164" height="50" rx="25" fill="#26243b" stroke="#7567f5" stroke-opacity=".8"/>
<text x="290" y="32" text-anchor="middle" fill="#ded9ff">Prompt AST</text>
<rect x="390" width="160" height="50" rx="25" fill="#1c2c3b" stroke="#64dc85" stroke-opacity=".7"/>
<text x="470" y="32" text-anchor="middle" fill="#d2ffe0">Smart Pipes</text>
</g>
</g>
<g filter="url(#shadow)">
<rect x="790" y="100" width="800" height="520" rx="24" fill="#13181f" stroke="#3b4858" stroke-width="2"/>
<g clip-path="url(#editorClip)">
<rect x="790" y="100" width="800" height="58" fill="#202730"/>
<circle cx="824" cy="129" r="7" fill="#2ca7ee"/>
<text x="845" y="137" fill="#fff" font-size="22" font-weight="700">BV Regional Editor</text>
<rect x="1378" y="115" width="92" height="30" rx="6" fill="#151b22" stroke="#465464"/>
<text x="1424" y="136" fill="#c7d3df" font-size="14" text-anchor="middle">Workspace</text>
<rect x="790" y="158" width="188" height="462" fill="#101419"/>
<text x="816" y="196" fill="#8fa0b2" font-size="15" font-weight="700">REGIONS</text>
<rect x="808" y="216" width="150" height="67" rx="8" fill="#19232d" stroke="#4c78a8"/>
<circle cx="826" cy="237" r="7" fill="#4c78a8"/><text x="842" y="243" fill="#e7eef6" font-size="16">Cyan subject</text>
<rect x="808" y="297" width="150" height="67" rx="8" fill="#2a2023" stroke="#e45756"/>
<circle cx="826" cy="318" r="7" fill="#e45756"/><text x="842" y="324" fill="#e7eef6" font-size="16">Amber subject</text>
<rect x="808" y="378" width="150" height="67" rx="8" fill="#17271d" stroke="#59b957"/>
<circle cx="826" cy="399" r="7" fill="#59b957"/><text x="842" y="405" fill="#e7eef6" font-size="16">Light arc</text>
<rect x="978" y="158" width="422" height="462" fill="#0b0f14"/>
<rect x="1023" y="204" width="332" height="370" rx="7" fill="#252d36"/>
<rect x="1045" y="230" width="151" height="316" fill="#4c78a8" opacity=".67"/>
<rect x="1184" y="230" width="149" height="316" fill="#e45756" opacity=".62"/>
<rect x="1162" y="330" width="56" height="116" fill="#9f70a0" opacity=".82"/>
<path d="M1041 270C1124 203 1251 210 1337 279" fill="none" stroke="#59b957" stroke-width="26" stroke-linecap="round" opacity=".7"/>
<rect x="1001" y="177" width="34" height="164" rx="14" fill="#171d24" stroke="#425061"/>
<circle cx="1018" cy="201" r="11" fill="#2ca7ee"/>
<rect x="1009" y="230" width="18" height="18" rx="2" fill="none" stroke="#dbe7f2" stroke-width="2"/>
<path d="M1009 271l18-10M1009 277l18-10" stroke="#dbe7f2" stroke-width="2"/>
<rect x="1400" y="158" width="190" height="462" fill="#101419"/>
<text x="1422" y="196" fill="#8fa0b2" font-size="15" font-weight="700">REGION PROMPT</text>
<text x="1422" y="230" fill="#cbd6e1" font-size="14">Strength</text>
<rect x="1422" y="241" width="144" height="34" rx="6" fill="#171d24" stroke="#384655"/>
<text x="1434" y="264" fill="#fff" font-size="15">0.85</text>
<text x="1422" y="312" fill="#cbd6e1" font-size="14">Positive</text>
<rect x="1422" y="324" width="144" height="112" rx="6" fill="#171d24" stroke="#384655"/>
<path d="M1436 346h102M1436 363h116M1436 380h94M1436 397h110" stroke="#8292a4" stroke-width="4" stroke-linecap="round" opacity=".7"/>
<text x="1422" y="475" fill="#cbd6e1" font-size="14">Negative</text>
<rect x="1422" y="487" width="144" height="75" rx="6" fill="#171d24" stroke="#384655"/>
<path d="M1436 509h111M1436 526h87" stroke="#8292a4" stroke-width="4" stroke-linecap="round" opacity=".7"/>
</g>
</g>
<path d="M720 360C750 360 758 360 790 360" fill="none" stroke="url(#cyan)" stroke-width="5" opacity=".9"/>
<circle cx="720" cy="360" r="7" fill="#64dc85"/>
<circle cx="790" cy="360" r="7" fill="#64dc85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400" role="img" aria-labelledby="title desc">
<title id="title">BV Node Pack</title>
<desc id="desc">BV Node Pack spiral mark on a dark rounded-square background.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#0a0f15"/>
<stop offset="1" stop-color="#182334"/>
</linearGradient>
<linearGradient id="brand" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#2ca7ee"/>
<stop offset="1" stop-color="#7567f5"/>
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="180%">
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#000" flood-opacity=".5"/>
</filter>
</defs>
<rect width="400" height="400" rx="88" fill="url(#bg)"/>
<circle cx="200" cy="200" r="126" fill="url(#brand)" filter="url(#shadow)"/>
<path d="M139 207c27-72 103-80 132-27 29 56-45 112-96 72-39-31-14-96 43-84 38 8 39 54 7 68" fill="none" stroke="#fff" stroke-width="25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
# BV Prompt AST Guide
BV Prompt AST stores prompt intent as structured blocks instead of repeatedly
parsing ad-hoc strings. The encoder emits both `BV_AST` and cleaned plain text;
downstream nodes can filter categories without losing the original hierarchy.
## Syntax
Unmarked text belongs to the default category:
```text
A beautiful landscape, sunset lighting
```
Block categories remain active until the next block marker:
```text
@@style
cinematic lighting
@@subject
a woman in rain
```
Inline categories delimit a smaller fragment:
```text
a portrait with @<eye> green eyes @@ and @<hair> black hair @@
```
Inline categories may be nested:
```text
@<subject> a @<face> smiling woman @@ in rain @@
```
Comments are removed while their line break is retained:
```text
a portrait ## authoring note, not part of the prompt
```
## Rules and failure cases
- Category names match `[a-zA-Z0-9_-]+`; spaces are not allowed.
- Every inline category must be closed with `@@`.
- An unmatched closing marker or unclosed category raises an error with line and column.
Invalid:
```text
@<clothing color> white dress @@
@<clothing> long dress,@@@@
```
Valid:
```text
@<clothing_color> white dress @@
@<clothing> long dress,@@
```
## Typical chain
1. **BV Prompt Encode** parses source text into `BV_AST`.
2. **BV Prompt Category Switch** enables or disables categories.
3. **BV Prompt Decode** materializes the selected plain prompt.
4. **BV Prompt AST Debug** exposes the current tree during authoring.
[![Prompt AST workflow](../examples/images/prompt-ast-categories.png)](../examples/images/prompt-ast-categories.png)
The workflow PNG contains importable ComfyUI metadata. A JSON fallback is
available at [`examples/workflows/prompt-ast-categories.json`](../examples/workflows/prompt-ast-categories.json).
+231
View File
@@ -0,0 +1,231 @@
# BV Regional Editor Guide
The BV Regional Editor authors a model-neutral `BV_REGIONAL` document. Geometry,
prompts and authoring metadata are stored once; model-specific compiler nodes
decide how much of that contract a backend can execute.
[![Anima regional result](../examples/images/anima-android-dance-regional-showcase.png)](../examples/images/anima-android-dance-regional-showcase.png)
The reference document behind this image uses two rectangle layers per character:
a main body area and a smaller shared interaction area. A third, painted region
guides the golden light arc.
[Import the workflow PNG](../examples/images/anima-android-dance-regional-showcase.png)
· [Import the regional document](examples/anima-android-dance-regional-showcase.bv-regional.json)
## Minimal workflow
1. Add **BV Regional Prompt**.
2. Open the editor from the node or the BV toolbar action.
3. Define Global and Background prompts.
4. Add named regions and create rectangle or brush geometry.
5. Connect `regional` to a supported compiler.
6. Connect the compiler outputs to a standard KSampler.
| Backend | Connect | Behavior |
| --- | --- | --- |
| Native/Krea-style | `BV Regional Native Conditioning` | Standard ComfyUI masked conditioning; no model attention patch |
| Anima | `BV Regional Anima Conditioning` | Built-in Anima attention patch; standard KSampler |
| External Anima fallback | `BV Regional Anima Adapter` | Compiles for the optional Sen-sou node pack |
## Editor layout
| Area | Purpose |
| --- | --- |
| Left panel | Regions, priority order, geometry layers, visibility, lock and selection |
| Artboard | WYSIWYG canvas, background preview, masks, selection handles, zoom and pan |
| Right panel | Region strength, feather, prompts and document prompts |
| Top menu | Import/export, undo/redo, display controls, canvas settings, completion data and help |
| Floating tool strip | Select, rectangle, additive brush and subtractive brush |
Panel widths, open sections, active selection, zoom/pan and window geometry are
saved separately per `document_id`. They never alter the semantic regional document.
<details>
<summary><strong>Workspace and floating layouts</strong></summary>
![Regional Editor in workspace mode](assets/regional/editor-workspace.png)
![Regional Editor in floating mode](assets/regional/editor-floating.png)
Floating mode remains at viewport scale while the graph is zoomed or panned.
</details>
<details>
<summary><strong>Node and toolbar entry points</strong></summary>
![BV Regional Prompt node](assets/regional/regional-prompt-node.png)
![BV toolbar actions](assets/regional/toolbar-actions.png)
![Open Regional Editor action](assets/regional/toolbar-editor-hover.png)
![Quick Edit action](assets/regional/toolbar-quick-hover.png)
</details>
## Geometry and layers
- Rectangles show a live preview while dragging and support move plus eight resize handles.
- Brush layers support round/square tips, size, hardness, opacity and optional stylus pressure.
- Subtractive brush strokes remove from the selected brush layer; they do not create white masks.
- Regions and layers can be renamed, reordered, locked, hidden, disabled, duplicated or deleted.
- Regions may overlap. Version 1 executes the overlap mode `joint`.
- Canvas width and height control the authoring aspect ratio even when no background exists.
Mask overlay opacity and background-image opacity are display-only settings. They
do not change exported masks, regional strength or the image returned by a sender.
<details>
<summary><strong>Display controls</strong></summary>
![Mask and background-image opacity controls](assets/regional/view-controls.png)
</details>
<details>
<summary><strong>Reference document geometry</strong></summary>
The main layers guide the two character designs. The smaller rectangles overlap
where their hands should meet, while the brush region demonstrates freehand geometry.
![Cyan main body](assets/regional/region-cyan-main.png)
![Cyan interaction area](assets/regional/region-cyan-interaction.png)
![Amber main body](assets/regional/region-amber-main.png)
![Amber interaction area](assets/regional/region-amber-interaction.png)
![Holographic light arc brush](assets/regional/region-brush-arc.png)
</details>
## Prompts
Each document contains:
- Global positive and negative prompts;
- Background positive and negative prompts;
- positive and negative prompts per region;
- per-region strength and feather;
- negative mode, including the technical zero-out path.
Global prompting establishes composition and concepts across the image. Regional
prompts should usually describe local attributes rather than duplicate the entire
scene. A region guides where conditioning applies; it is not a hard object boundary.
## How strict is regional guidance?
Regional prompting and attention masking bias where prompt information is available;
they do not clip a generated object to a mask. Results still depend on the model,
seed, sampler, prompt composition, mask geometry and backend implementation.
- A subject may extend beyond its region or appear slightly offset from it.
- Attributes may bleed between nearby or strongly overlapping regions.
- A small interaction region can make both regional contexts available around a
contact point, but it cannot guarantee joined hands, eye contact or another pose.
- Region strength controls conditioning influence. Raising it does not turn the
region into a stricter bounding box and can overemphasize local concepts.
- Global prompts can still influence composition and placement across the full image.
Treat masks as spatial intent. Validate important layouts across several seeds and
adjust geometry, prompt wording and backend settings together.
## Workspace, floating and Quick Edit
Workspace mode fills the available browser viewport. Floating mode is independently
movable and resizable and remains unaffected by graph zoom or pan.
Quick Edit changes Global, Background or one selected region without opening the
full artboard. It targets stable document and region IDs even though the UI shows names.
<details>
<summary><strong>Quick Edit over a live workflow</strong></summary>
![Regional Quick Edit](assets/regional/quick-edit.png)
</details>
## Background feedback
- **BV Regional Preview Send** returns a temporary preview to a selected editor.
- **BV Regional Save Send** saves normally and returns the saved result.
- Multiple senders use last-completed-sender-wins semantics.
- Images are not embedded as Base64 in the workflow document.
This makes it possible to redraw masks over the latest sampler, detailer or upscale
result without creating a workflow cycle.
## Prompt autocomplete
BV Prompt Autocomplete works in the full editor, Quick Edit and ordinary ComfyUI
multiline widgets. Tags from the default dataset are inserted with spaces while the
canonical underscore form remains available as metadata.
Autocomplete is optional. Disable it under **Settings → BV Node Pack → Prompting**
or from **Edit → Prompt Autocomplete** when another extension should own prompt fields.
Several CSV/TSV datasets may be enabled and ordered; the topmost source wins duplicate tags.
<details>
<summary><strong>Completion surfaces</strong></summary>
![Autocomplete in Quick Edit](assets/regional/autocomplete-quick-edit.png)
![Autocomplete in the full editor](assets/regional/autocomplete-editor.png)
![Autocomplete in a native multiline widget](assets/regional/autocomplete-multiline.png)
</details>
<details>
<summary><strong>Dataset settings and priority</strong></summary>
![Completion dataset settings](assets/regional/completion-settings.png)
</details>
## Document tools
| Node | Use |
| --- | --- |
| BV Regional Debug | Canonical JSON, summary and stable `document_id` |
| BV Regional Select | Select Global, Background or a named region |
| BV Regional Deconstructor | AST, plain text, source text and reusable selection |
| BV Regional Prompt Extract | Extract positive/negative AST and text |
| BV Regional Mask Render | Render mask plus pixel bounding box |
These seams are intended for later detailer adapters and other consumers without
coupling them to the editor UI.
## Anima reference workflow
The built-in Anima path has been verified with two independently conditioned
characters, joint interaction overlap, a painted light region and a standard KSampler.
<details>
<summary><strong>Minimal workflow</strong></summary>
![Anima regional workflow](assets/regional/anima-workflow.png)
</details>
Import either the metadata-bearing
[`workflow PNG`](../examples/images/anima-android-dance-regional-showcase.png)
or the editor-only
[`regional document`](examples/anima-android-dance-regional-showcase.bv-regional.json).
## Current limits
- `joint` is the only executable overlap mode; normalized, exclusive and priority modes are reserved.
- Native masked conditioning does not provide model-internal attention isolation.
- The built-in attention backend currently targets Anima; other model families require future adapters.
- Parent-region IDs exist in the schema, but hierarchy authoring is not exposed yet.
- Regional negative isolation depends on backend capability.
- A region is conditioning guidance, not a guaranteed object-sized bounding box,
exact pose constraint or pixel-perfect interaction target.
For the formal schema and implementation boundaries, see
[`docs/specs/bv-regional-v1.md`](specs/bv-regional-v1.md) and
[`docs/regional-editor-mvp.md`](regional-editor-mvp.md).
+7 -8
View File
@@ -21,7 +21,6 @@ In Version 1 ist nur die Overlap-Semantik `joint` ausführbar. Die reservierten
- `BV Regional Preview Send` (interne kompatible Klassenkennung `BV Regional Image Send`): sendet ein temporäres Preview-Ergebnis an den Editor eines ausgewählten `BV Regional Prompt`. Die sichtbare Auswahl verwendet Node-Titel und Node-ID, während intern die stabile `document_id` gespeichert wird. Bei mehreren ausgeführten Sendern gewinnt das zuletzt beim Frontend eingetroffene Ergebnis; entfernte Ziele werden nicht stillschweigend auf ein anderes Dokument umgebogen.
- `BV Regional Save Send` (interne Klassenkennung `BV Regional Image Save`): speichert das Bild regulär im ComfyUI-Output, sendet dasselbe gespeicherte Ergebnis an den ausgewählten Editor und reicht das Bild am Ausgang weiter. Zielauflösung und Last-Sender-Wins-Semantik entsprechen der Preview-Variante.
Das modellbezogene Backend wird erst beim Ausführen dieser Node importiert. Fehlende optionale Abhängigkeiten oder inkompatible ComfyUI-Patcher-Schnittstellen dürfen deshalb nicht die Registrierung des übrigen BV NodePacks verhindern.
- `BV Regional Image Send`: IMAGE-Passthrough, das eine temporäre Preview an eine `document_id` sendet. Pro Dokument gewinnt die zuletzt ausgeführte Sender-Node.
## Editor-Verhalten
@@ -75,23 +74,23 @@ Geometrieebenen besitzen dafür rückwärtskompatible optionale Felder `enabled`
## Hintergrundbilder
`BV Regional Image Send` speichert ausschließlich eine ComfyUI-Temp-Preview. Die Browser-Session ordnet das letzte Ergebnis anhand der `document_id` zu. Das Bild wird nicht in `BV_REGIONAL` und nicht als Base64 im Workflow persistiert. Ein Workflow-Reload beginnt daher absichtlich ohne Sender-Hintergrund, bis die Sender-Node erneut ausgeführt wird.
`BV Regional Preview Send` speichert ausschließlich eine ComfyUI-Temp-Preview. Die Browser-Session ordnet das letzte Ergebnis anhand der `document_id` zu. Das Bild wird nicht in `BV_REGIONAL` und nicht als Base64 im Workflow persistiert. Ein Workflow-Reload beginnt daher absichtlich ohne Sender-Hintergrund, bis die Sender-Node erneut ausgeführt wird.
## Geplante Editor-Erweiterungen
## Prompt-Bearbeitung und Completion
### Prompt-Schnellbearbeitung
Ohne den vollständigen Editor zu öffnen, soll eine kompakte Prompt-Palette erreichbar sein. Sie bietet direkte Ziele für Global, Background und jede Region und zeigt jeweils Positive und Negative als editierbare Textfelder. Die Zielauswahl verwendet sichtbare Namen, speichert intern jedoch stabile IDs. Änderungen laufen durch dieselbe Validierungs-, AST- und Undo-Pipeline wie Änderungen im vollständigen Editor; es entsteht keine zweite Prompt-Datenquelle.
Ohne den vollständigen Editor zu öffnen, ist eine kompakte Prompt-Palette erreichbar. Sie bietet direkte Ziele für Global, Background und jede Region und zeigt jeweils Positive und Negative als editierbare Textfelder. Die Zielauswahl verwendet sichtbare Namen, speichert intern jedoch stabile IDs. Änderungen laufen durch dieselbe Validierungs- und Dokumentpipeline wie Änderungen im vollständigen Editor; es entsteht keine zweite Prompt-Datenquelle.
Die Schnellbearbeitung soll sowohl über die jeweilige `BV Regional Prompt`-Node als auch über die globale Action-Bar-Oberfläche für das aktuell gewählte Dokument erreichbar sein. Bei mehreren Editor-Nodes muss vor dem Bearbeiten eindeutig sichtbar sein, welches Dokument aktiv ist.
Die Schnellbearbeitung ist sowohl über die jeweilige `BV Regional Prompt`-Node als auch über die globale Action-Bar-Oberfläche erreichbar. Bei mehreren Editor-Nodes zeigt die Dokumentauswahl eindeutig, welches Dokument aktiv ist; das zuletzt gewählte Prompt-Ziel wird pro Dokument gespeichert.
### Autovervollständigung und Textassistenz
Die Promptfelder verwenden `BV Global Completion`, ein nodepack-weites Completion-Modul mit austauschbarer Suggestion-Provider-Schnittstelle. Dieselbe Engine integriert neben BV React-Editoren auch geeignete klassische ComfyUI- und Nodes-2.0-Multiline-Widgets, sodass Benutzer kein zweites Completion-System benötigen. Details und Integrationsverträge stehen in `docs/specs/bv-global-completion.md`.
Die Promptfelder verwenden `BV Global Completion`, ein nodepack-weites Completion-Modul mit austauschbarer Suggestion-Provider-Schnittstelle. Dieselbe Engine integriert neben BV React-Editoren auch geeignete klassische ComfyUI- und Nodes-2.0-Multiline-Widgets. Sie verwendet lokale CSV-/TSV-Datasets, unterstützt priorisierte Quellen und kann global oder im Editor deaktiviert werden. Details und Integrationsverträge stehen in `docs/specs/bv-global-completion.md`.
`comfy-ex-tagcomplete` und vergleichbare Widget-Extensions können abgekoppelte React-/Floating-Textfelder nicht über einen gemeinsamen stabilen nativen Opt-in-Hook registrieren. BV emuliert deshalb weder Node-DOM noch importiert es private Frontendklassen fremder Extensions. Normale `<textarea>`-Semantik und bubbling `input`-Events bleiben für allgemeine Interoperabilität erhalten. Bridges zu einzelnen Fremdsystemen sind außerhalb des MVP als optionale Adapter denkbar, dürfen aber weder Kernfunktion noch lokales Datenformat bestimmen.
Das Completion-System wird von Beginn an für weitere Providerklassen vorbereitet: lokale Tagdaten, Embeddings, LoRAs, Wildcards, AST-/Kontextvorschläge sowie später Rechtschreib-, Übersetzungs- oder Sprachassistenz. Vorschläge verändern den kanonischen Prompt niemals ohne explizite Benutzerübernahme und jede Übernahme muss über den normalen Editor-Undo-Pfad rückgängig sein.
Das Completion-System ist für weitere Providerklassen vorbereitet: Embeddings, LoRAs, Wildcards, AST-/Kontextvorschläge sowie später Rechtschreib-, Übersetzungs- oder Sprachassistenz. Vorschläge verändern den kanonischen Prompt niemals ohne explizite Benutzerübernahme.
Rechtschreib- oder Sprachkorrektur wird als separater optionaler Text-Assistant vorgesehen. Sie darf niemals ungefragt den kanonischen Prompt verändern und muss Vorschlag, Übernahme und Rücknahme klar trennen. Eine mögliche TextLab-Integration wird erst nach Prüfung ihrer konkreten Schnittstelle festgelegt.
@@ -112,7 +111,7 @@ Interaktiv verifiziert:
- Krea 2 über `BV Regional Native Conditioning`: lokale Farb-/Musterzuordnung und Prompt-Swap funktionieren; getrennte Instanzerzeugung ohne Global-Komposition bleibt eine Grenze des nativen Backends.
- Anima über den eingebauten `BV Regional Anima Conditioning`-Patcher: zwei getrennte Charaktere, regionale Haar-/Outfit-Zuordnung, Background-Routing und normaler KSampler funktionieren im realen GPU-Lauf. Der externe Sen-sou-Apply-Node war dabei nicht Bestandteil des Ausführungspfads.
Vor dem ersten Release noch interaktiv in ComfyUI prüfen:
Interaktive Smoke-Test-Matrix für Releases:
1. Node anlegen, Editor über Node und Action Bar öffnen.
2. Zwei `BV Regional Prompt`-Nodes anlegen und im Editor wechseln.
@@ -0,0 +1,172 @@
# ComfyUI Nodes Manager tile metadata research
Research date: 2026-08-17
## Conclusion
The wide image at the top of a node-pack tile in the current Nodes Manager is
the Registry **banner**, not a README image. It is configured together with a
separate square icon in `pyproject.toml`:
```toml
[tool.comfy]
PublisherId = "blackvortex"
DisplayName = "BV-NodePack"
Icon = "https://raw.githubusercontent.com/BlackVortexAI/bv_nodepack/main/docs/assets/registry/bv-nodepack-icon.png"
Banner = "https://raw.githubusercontent.com/BlackVortexAI/bv_nodepack/main/docs/assets/registry/bv-nodepack-banner.png"
```
The official specification says both fields are used by the Comfy Registry and
ComfyUI-Manager. `Icon` must be SVG, PNG, JPG, or GIF, square, and no larger
than 400 x 400 pixels. `Banner` accepts the same file formats and must be 21:9.
The specification does not currently state a maximum banner resolution. See
the official [`pyproject.toml` specification](https://docs.comfy.org/registry/specifications#icon-optional).
For BV, create and publish both assets. A 400 x 400 PNG or SVG icon and a
1680 x 720 (21:9) PNG banner are conservative choices. The banner should carry
the recognizable BV visual identity and remain readable after center-cropping;
the icon should work without small text. The URL must be publicly and directly
fetchable. The official example uses `raw.githubusercontent.com`.
## Current BV repository state
The repository already has the main Registry metadata and an automated publish
workflow:
- `[project].name = "bv_nodepack"`
- `[project].version = "0.3.1"`
- a detailed `[project].description`
- `[project.urls]` for repository, issues, and documentation
- `[tool.comfy].PublisherId = "blackvortex"`
- `[tool.comfy].DisplayName = "BV-NodePack"`
- `.github/workflows/publish.yml` using the official publish action
The missing pieces for the visual tile are that `Icon` is currently empty and
`Banner` is absent. The existing `docs/assets/bv-nodepack-hero.svg` is a README
hero and is not automatically discovered as Registry artwork.
Recommended repository layout:
```text
docs/assets/registry/
bv-nodepack-icon.svg # or .png; square, max 400 x 400
bv-nodepack-banner.png # 21:9, e.g. 1680 x 720
```
After the files exist on the public GitHub repository, add their raw URLs to
`[tool.comfy]`. Do not point at a local path or at the normal GitHub HTML file
page. A branch-based `main` URL is easy to maintain; an immutable commit URL is
more reproducible but must be changed whenever the artwork changes. Image-cache
delay when replacing content behind an unchanged URL is a practical possibility,
not a behavior guaranteed by the official documentation.
## Metadata that drives the listing
The documented author-controlled fields are:
| Field | Status | Listing purpose |
| --- | --- | --- |
| `[project].name` | required | Globally unique Registry node ID and install name; immutable after creation |
| `[project].version` | required | Three-part semantic version used for releases and upgrades |
| `[project].description` | recommended | Short node-pack summary shown by Registry/Manager clients |
| `[project.urls].Repository` | required | Source repository |
| `[project.urls].Documentation` | recommended | Documentation destination |
| `[project.urls]."Bug Tracker"` | recommended | Support/issues destination |
| `[tool.comfy].PublisherId` | required | Globally unique Registry publisher identity |
| `[tool.comfy].DisplayName` | optional | User-facing pack name; can be changed later |
| `[tool.comfy].Icon` | optional | Square icon URL, max 400 x 400 |
| `[tool.comfy].Banner` | optional | Wide 21:9 banner URL |
| `[tool.comfy].requires-comfyui` | optional | Compatible ComfyUI version constraint |
| `[project].dependencies` | optional | Python/frontend package requirements; the frontend package can express frontend-version compatibility |
| `[project].classifiers` | recommended | OS and accelerator discovery metadata |
These definitions and validation rules come from the official
[`pyproject.toml` specification](https://docs.comfy.org/registry/specifications).
The Registry API response additionally contains tags, compatibility projections,
download counts, ratings, repository stars, status, and publisher data. Those
fields explain other card/filter data, but the current `pyproject.toml`
specification does not document a free-form `[tool.comfy]` tags key. BV should
therefore not invent one. See the official
[`GET /nodes/{nodeId}` schema](https://docs.comfy.org/registry/api-reference/nodes/retrieve-a-specific-node-by-id).
The current BV description is accurate but very long for a compact card. A
shorter first sentence would improve scanning, for example:
> Regional prompting, structured prompts, Smart Pipes, Subgraph controls, and
> workflow utilities for current ComfyUI.
This is a content recommendation, not a Registry length requirement; the
official specification only calls for a brief description.
## Publishing and update behavior
The Comfy Registry is the backend that powers ComfyUI-Manager. The supported
publication flow is:
1. Create a publisher at the Comfy Registry. Its ID is globally unique and
cannot be changed later.
2. Create a Registry publishing API key.
3. Maintain the metadata in `pyproject.toml`.
4. Increment `[project].version` using semantic versioning.
5. Publish with `comfy node publish`, or run the official GitHub publish action.
The official publishing guide explicitly shows a workflow triggered by changes
to `pyproject.toml`, and says that pushing an updated version causes the updated
node to appear in the Registry. See
[`Publishing Nodes`](https://docs.comfy.org/registry/publishing#publish-to-the-registry).
BV already has `.github/workflows/publish.yml` and therefore only needs the
assets, metadata entries, a version increment, and a successful workflow run.
The repository secret `REGISTRY_ACCESS_TOKEN` must exist for that action.
Published versions are immutable. To change packaged contents, publish a new
version; a released version cannot be overwritten. A faulty version can be
deprecated in the Registry UI, which warns installed users and directs them to
a newer version. See the official
[`Registry overview`](https://docs.comfy.org/registry/overview#frequently-asked-questions).
The official Registry API also exposes an update operation for node-level
metadata, including `banner_url`, `description`, `icon`, repository, tags, and
compatibility fields. This proves that visual metadata is node-level data, but
the documented, repository-centered workflow remains `pyproject.toml` plus a
new publish. See
[`Update a specific node`](https://docs.comfy.org/api-reference/registry/update-a-specific-node).
## Documentation inconsistency
The publishing tutorial's generated example still comments that `Icon` permits
"MAX. 800x400px". That conflicts with the dedicated current specification,
which defines `Icon` as square and at most 400 x 400 and defines `Banner`
separately as 21:9. Treat the dedicated specification as normative: use a
maximum 400 x 400 square icon and a separate 21:9 banner. Compare
[`Publishing Nodes: Add Metadata`](https://docs.comfy.org/registry/publishing#add-metadata)
with the
[`pyproject.toml` Icon/Banner specification](https://docs.comfy.org/registry/specifications#icon-optional).
## Legacy Manager boundary
The old Manager database is not the place to add this artwork. The official
ComfyUI-Manager repository describes `node_db` as the JSON database powering
the **legacy** registry system and identifies the online Custom Node Registry as
the replacement. See the official
[`node_db` README](https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/node_db/README.md).
Therefore:
- do not add image metadata to the README and expect Manager discovery;
- do not submit a local/legacy `custom-node-list.json` image change for this;
- publish `Icon` and `Banner` through BV's Registry `pyproject.toml` metadata.
## Concrete BV implementation checklist
1. Design a square BV icon, at most 400 x 400, without small copy.
2. Design a 21:9 BV banner (recommended working size: 1680 x 720) with safe
margins and minimal text.
3. Commit both under `docs/assets/registry/` and verify their raw GitHub URLs
return the image directly without authentication.
4. Set both `Icon` and `Banner` in `[tool.comfy]`.
5. Optionally shorten the project description's opening sentence for the card.
6. Increment the semantic version; do not try to replace release `0.3.1`.
7. Push and confirm the publish workflow succeeds.
8. Verify the Registry detail page and current Nodes Manager tile at normal and
narrow widths; specifically inspect crop, contrast, transparent SVG handling,
description truncation, and icon/banner fallback.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+10 -10
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "bv_nodepack"
version = "0.3.1"
description = "Advanced ComfyUI workflow toolkit featuring a model-neutral regional prompting editor, native and Anima conditioning backends, optional local CSV/TSV prompt completion for BV and ordinary ComfyUI multiline fields, image feedback senders, structured AST prompts, wireless Smart Pipes, Subgraph-native controls, composable UI nodes, and flexible latent utilities."
version = "0.3.2"
description = "Regional prompting, structured prompts, Smart Pipes, Subgraph controls, local autocomplete, and workflow utilities for current ComfyUI."
authors = [
{name = "BlackVortex", email = "blackvortexx@proton.me"}
]
@@ -18,4 +18,5 @@ Documentation = "https://github.com/BlackVortexAI/bv_nodepack/wiki"
[tool.comfy]
PublisherId = "blackvortex"
DisplayName = "BV-NodePack"
Icon = ""
Icon = "https://raw.githubusercontent.com/BlackVortexAI/bv_nodepack/main/docs/assets/registry/bv-nodepack-icon.png"
Banner = "https://raw.githubusercontent.com/BlackVortexAI/bv_nodepack/main/docs/assets/registry/bv-nodepack-banner.png"
+10 -1
View File
@@ -18,16 +18,25 @@ test("editor state is isolated by stable document id", () => {
});
test("invalid persisted values are normalized without entering semantic document data", () => {
const normalized = normalizeEditorState({ mode: "garbage", panels: { left: -1, right: 9000 }, artboard: { zoom: 99 }, displayOpacity: -4 }, viewport);
const normalized = normalizeEditorState({ mode: "garbage", panels: { left: -1, right: 9000 }, artboard: { zoom: 99 }, displayOpacity: -4, backgroundOpacity: 4 }, viewport);
assert.equal(normalized.version, 1);
assert.equal(normalized.mode, "workspace");
assert.deepEqual(normalized.panels, { left: 210, right: 620 });
assert.equal(normalized.artboard.zoom, 8);
assert.equal(normalized.displayOpacity, .05);
assert.equal(normalized.backgroundOpacity, 1);
assert.equal(normalized.openMenu, null);
assert.equal("document_id" in normalized, false);
});
test("background display opacity survives save and reload per document", () => {
const storage = memoryStorage(), state = defaultEditorState(viewport);
state.backgroundOpacity = .35;
saveEditorState("doc", state, storage);
assert.equal(loadEditorState("doc", storage, viewport).backgroundOpacity, .35);
assert.equal(loadEditorState("other-doc", storage, viewport).backgroundOpacity, 1);
});
test("floating windows remain recoverable after viewport changes", () => {
const value = clampWindowGeometry({ x: 5000, y: -300, width: 1200, height: 700 }, { width: 1000, height: 700 }, "floating");
assert.ok(value.x <= 920);
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { preserveDocumentIdentity } from "../ui/src/regional/model.ts";
const source = readFileSync(new URL("../ui/src/regional/RegionalEditor.tsx", import.meta.url), "utf8");
test("the full editor reloads the node document whenever it opens", () => {
assert.match(source, /if \(!open \|\| !node\) return;/);
assert.match(source, /\}, \[node, open\]\);/);
});
test("replacing document content preserves the target editor identity", () => {
const imported = { document_id: "example-document", title: "Imported example" };
const replaced = preserveDocumentIdentity(imported, "active-editor");
assert.equal(replaced.document_id, "active-editor");
assert.equal(replaced.title, "Imported example");
assert.equal(imported.document_id, "example-document");
});
+13 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { regionsInPaintOrder, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "../ui/src/regional/interaction.ts";
import { brushAddLayerTarget, regionsInPaintOrder, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "../ui/src/regional/interaction.ts";
test("select ignores pointer jitter instead of mutating a compound brush mask", () => {
assert.equal(shouldStartSelectionMove({ x: 100, y: 100 }, { x: 101, y: 101 }), false);
@@ -28,3 +28,15 @@ test("only drawing appends the pointer-up point to a brush stroke", () => {
assert.equal(shouldAppendFinalBrushPoint("move"), false);
assert.equal(shouldAppendFinalBrushPoint("resize"), false);
});
test("brush add extends the selected editable brush layer", () => {
const layer = { id: "brush-layer", enabled: true, authoring: { locked: false }, geometries: [{ type: "brush_stroke" }] };
assert.equal(brushAddLayerTarget(layer), "brush-layer");
});
test("brush add creates a layer when the selection cannot be extended", () => {
assert.equal(brushAddLayerTarget(null), null);
assert.equal(brushAddLayerTarget({ id: "rectangle", enabled: true, authoring: { locked: false }, geometries: [{ type: "rect" }] }), null);
assert.equal(brushAddLayerTarget({ id: "locked", enabled: true, authoring: { locked: true }, geometries: [{ type: "brush_stroke" }] }), null);
assert.equal(brushAddLayerTarget({ id: "disabled", enabled: false, authoring: { locked: false }, geometries: [{ type: "brush_stroke" }] }), null);
});
+1
View File
@@ -100,6 +100,7 @@
.artboard-viewport.pan-ready:active { cursor: grabbing; }
.artboard-stage { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }
.bv-regional-canvas { position: relative; background-color: #24282d; background-image: linear-gradient(45deg,#2c3137 25%,transparent 25%),linear-gradient(-45deg,#2c3137 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#2c3137 75%),linear-gradient(-45deg,transparent 75%,#2c3137 75%); background-size: 24px 24px; background-position: 0 0,0 12px,12px -12px,-12px 0; touch-action: none; user-select: none; box-shadow: 0 4px 28px #000a; }
.bv-background-image { position: absolute; inset: 0; background-size: cover; background-repeat: no-repeat; pointer-events: none; }
.bv-composition, .bv-selection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; }
.bv-composition { pointer-events: none; }
.bv-selection-overlay { overflow: visible; pointer-events: none; }
+11 -1
View File
@@ -17,6 +17,7 @@ type Props = {
brush: BrushSettings;
canSubtract: boolean;
displayOpacity: number;
backgroundOpacity: number;
isolate: boolean;
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void;
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
@@ -103,9 +104,18 @@ export default function Artboard(props: Props) {
<ToolPalette tool={props.tool} brush={props.brush} canSubtract={props.canSubtract} canvas={document.canvas} onTool={props.onTool} onBrush={props.onBrush}/>
<div className="artboard-stage" style={{ width: document.canvas.width, height: document.canvas.height, transform: `translate(${stageLeft}px, ${stageTop}px) scale(${zoom})` }}><div
className={`bv-regional-canvas tool-${props.tool}`}
style={{ width: document.canvas.width, height: document.canvas.height, backgroundImage: props.background ? `linear-gradient(#0002,#0002), url(${JSON.stringify(props.background)})` : undefined, backgroundSize: props.background ? "cover" : undefined }}
style={{ width: document.canvas.width, height: document.canvas.height }}
onPointerDown={props.onPointerDown} onPointerMove={props.onPointerMove} onPointerUp={props.onPointerUp} onPointerCancel={props.onPointerCancel} onPointerLeave={props.onPointerLeave}
>
{props.background && (
<div
className="bv-background-image"
style={{
backgroundImage: `linear-gradient(#0002,#0002), url(${JSON.stringify(props.background)})`,
opacity: props.backgroundOpacity,
}}
/>
)}
<svg className="bv-composition" viewBox={`0 0 ${document.canvas.width} ${document.canvas.height}`} preserveAspectRatio="none">{regionsInPaintOrder(document.regions.filter(region => region.enabled && region.authoring.visible)).map(region => <RegionMask key={region.id} region={region} geometries={geometriesFor(region)} canvas={document.canvas} opacity={props.displayOpacity}/>)}</svg>
{props.selectionBounds && <svg className="bv-selection-overlay" viewBox={`0 0 ${document.canvas.width} ${document.canvas.height}`} preserveAspectRatio="none">
<rect className="brush-bounds" x={props.selectionBounds.x * document.canvas.width} y={props.selectionBounds.y * document.canvas.height} width={props.selectionBounds.width * document.canvas.width} height={props.selectionBounds.height * document.canvas.height}/>
+3 -1
View File
@@ -5,9 +5,11 @@ import DatasetPicker from "../completion/DatasetPicker";
type Props = {
displayOpacity: number;
backgroundOpacity: number;
isolate: boolean;
hasSelection: boolean;
onDisplayOpacity: (value: number) => void;
onBackgroundOpacity: (value: number) => void;
onToggleIsolate: () => void;
onExportDocument: () => void;
onExportRegions: () => void;
@@ -41,7 +43,7 @@ export default function EditorMenus(props: Props) {
<button onClick={() => { regionsInput.current?.click(); props.onOpenMenu(null); }}>Add Regions</button>
</div>}</div>
<div className="menu-item"><button onClick={() => toggle("edit")}>Edit</button>{menu === "edit" && <div className="menu-popover completion-menu"><button disabled={!props.canUndo} onClick={props.onUndo}>Undo <kbd>Ctrl Z</kbd></button><button disabled={!props.canRedo} onClick={props.onRedo}>Redo <kbd>Ctrl Shift Z</kbd></button><hr/><button className={autocomplete ? "active" : ""} onClick={() => setCompletionEnabled(!autocomplete, true)}>{autocomplete ? "✓ " : ""}Prompt Autocomplete</button><small>Optional. Disable it when using another completion extension.</small><hr/><DatasetPicker/></div>}</div>
<div className="menu-item"><button onClick={() => toggle("view")}>View</button>{menu === "view" && <div className="menu-popover view-menu"><label>Mask Display <output>{Math.round(props.displayOpacity * 100)} %</output><input type="range" min=".05" max="1" step=".05" value={props.displayOpacity} onChange={event => props.onDisplayOpacity(+event.target.value)}/></label><button disabled={!props.hasSelection} className={props.isolate ? "active" : ""} onClick={props.onToggleIsolate}>Isolate Selection</button><small>Affects the editor display only.</small></div>}</div>
<div className="menu-item"><button onClick={() => toggle("view")}>View</button>{menu === "view" && <div className="menu-popover view-menu"><label>Mask Display <output>{Math.round(props.displayOpacity * 100)} %</output><input type="range" min=".05" max="1" step=".05" value={props.displayOpacity} onChange={event => props.onDisplayOpacity(+event.target.value)}/></label><label>Background Image <output>{Math.round(props.backgroundOpacity * 100)} %</output><input type="range" min="0" max="1" step=".05" value={props.backgroundOpacity} onChange={event => props.onBackgroundOpacity(+event.target.value)}/></label><button disabled={!props.hasSelection} className={props.isolate ? "active" : ""} onClick={props.onToggleIsolate}>Isolate Selection</button><small>Affects the editor display only.</small></div>}</div>
<div className="menu-item"><button onClick={() => toggle("artboard")}>Artboard</button>{menu === "artboard" && <div className="menu-popover artboard-menu"><label>Width<input type="number" min="1" value={props.canvas.width} onChange={event => props.onCanvas({ ...props.canvas, width: Math.max(1, +event.target.value) })}/></label><label>Height<input type="number" min="1" value={props.canvas.height} onChange={event => props.onCanvas({ ...props.canvas, height: Math.max(1, +event.target.value) })}/></label></div>}</div>
<div className="menu-item"><button onClick={() => toggle("layers")}>Layers</button>{menu === "layers" && <div className="menu-popover"><button disabled title="Planned feature">Split Disconnected Areas…</button><small>Connected-component detection will follow in a later iteration.</small></div>}</div>
<div className="menu-item"><button className="help-button" title="Keyboard and mouse commands" onClick={() => toggle("help")}>?</button>{menu === "help" && <div className="menu-popover help-menu"><section className="help-section"><strong>Keyboard</strong><dl><dt>Ctrl Z</dt><dd>Undo</dd><dt>Ctrl Shift Z</dt><dd>Redo</dd><dt>Escape</dt><dd>Cancel action</dd><dt>Delete</dt><dd>Delete layer</dd><dt>Shift + Resize</dt><dd>Scale proportionally</dd><dt>Space + Drag</dt><dd>Pan artboard</dd></dl></section><section className="help-section"><strong>Mouse</strong><dl><dt>Click</dt><dd>Select layer</dd><dt>Drag</dt><dd>Draw or move</dd><dt>Drag handles</dt><dd>Resize</dd><dt>Mouse wheel</dt><dd>Zoom under cursor</dd><dt>Middle button + Drag</dt><dd>Pan artboard</dd><dt>Double-click name</dt><dd>Rename</dd></dl></section></div>}</div>
+14 -13
View File
@@ -5,8 +5,8 @@ import LayerPanel from "./LayerPanel";
import OptionsPanel from "./OptionsPanel";
import { BrushSettings, Tool } from "./ToolPalette";
import { Bounds, boundsOfLayer, Handle, hitTest, moveLayer, resizeLayer, setLayerBounds, simplifyPoints } from "./geometry";
import { regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "./interaction";
import { clone, Geometry, geometryAuthoring, geometryLayerId, geometryLayers, newRegion, parseDocument, Point, RegionalDocument, Region, uuid } from "./model";
import { brushAddLayerTarget, regionsInPriorityOrder, shouldAppendFinalBrushPoint, shouldStartSelectionMove, synchronizeRegionPriorities } from "./interaction";
import { clone, Geometry, geometryAuthoring, geometryLayerId, geometryLayers, newRegion, parseDocument, Point, preserveDocumentIdentity, RegionalDocument, Region, uuid } from "./model";
import { activeWindowGeometry, applyObservedWindowSize, clampWindowGeometry, defaultEditorState, EditorViewState, loadEditorState, observedWindowSize, saveEditorState } from "./editorState";
type NodeRef = { id: number | string; title?: string; widgets?: Array<{ name: string; value: unknown; callback?: (value: unknown) => void }>; graph?: { setDirtyCanvas?: (a: boolean, b: boolean) => void } };
@@ -44,7 +44,7 @@ export default function RegionalEditor({ open, nodes, initialNode, backgrounds,
const [node, setNode] = useState<NodeRef | null>(initialNode), [documentValue, setDocumentValue] = useState<RegionalDocument | null>(null);
const [selectedRegionId, setSelectedRegionId] = useState<string | null>(null), [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [tool, setTool] = useState<Tool>("select"), [brush, setBrush] = useState<BrushSettings>({ size: .04, hardness: .75, opacity: 1, shape: "round", pressureMode: "constant" });
const [displayOpacity, setDisplayOpacity] = useState(.5), [isolate, setIsolate] = useState(false);
const [displayOpacity, setDisplayOpacity] = useState(.5), [backgroundOpacity, setBackgroundOpacity] = useState(1), [isolate, setIsolate] = useState(false);
const [history, setHistory] = useState<RegionalDocument[]>([]), [future, setFuture] = useState<RegionalDocument[]>([]);
const [draft, setDraft] = useState<Geometry[] | null>(null), [cursor, setCursor] = useState<Point | null>(null), [error, setError] = useState("");
const [viewState, setViewState] = useState<EditorViewState>(() => defaultEditorState());
@@ -57,20 +57,20 @@ export default function RegionalEditor({ open, nodes, initialNode, backgrounds,
useEffect(() => { if (initialNode) setNode(initialNode); else if (!node && nodes[0]) setNode(nodes[0]); }, [initialNode, node, nodes]);
useEffect(() => {
if (!node) return;
if (!open || !node) return;
try {
const next = parseDocument(getWidget(node)?.value); next.regions = regionsInPriorityOrder(next.regions); syncPriorities(next.regions);
const stored = loadEditorState(next.document_id), regionId = next.regions.some(region => region.id === stored.selectedRegionId) ? stored.selectedRegionId : next.regions[0]?.id ?? null;
const region = next.regions.find(item => item.id === regionId), layerId = region && geometryLayers(region).some(layer => layer.id === stored.selectedLayerId) ? stored.selectedLayerId : null;
setViewState(stored); setDocumentValue(next); setSelectedRegionId(regionId); setSelectedLayerId(layerId); setTool(stored.tool); setBrush(stored.brush); setDisplayOpacity(stored.displayOpacity); setIsolate(stored.isolate && !!layerId); setHistory([]); setFuture([]); setError("");
setViewState(stored); setDocumentValue(next); setSelectedRegionId(regionId); setSelectedLayerId(layerId); setTool(stored.tool); setBrush(stored.brush); setDisplayOpacity(stored.displayOpacity); setBackgroundOpacity(stored.backgroundOpacity); setIsolate(stored.isolate && !!layerId); setHistory([]); setFuture([]); setError("");
}
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
}, [node]);
}, [node, open]);
useEffect(() => {
if (!documentValue) return;
saveEditorState(documentValue.document_id, { ...viewState, selectedRegionId, selectedLayerId, displayOpacity, isolate, tool, brush });
}, [brush, displayOpacity, documentValue, isolate, selectedLayerId, selectedRegionId, tool, viewState]);
saveEditorState(documentValue.document_id, { ...viewState, selectedRegionId, selectedLayerId, displayOpacity, backgroundOpacity, isolate, tool, brush });
}, [backgroundOpacity, brush, displayOpacity, documentValue, isolate, selectedLayerId, selectedRegionId, tool, viewState]);
useEffect(() => {
if (!open || !shell.current || viewState.mode !== "floating") return;
@@ -156,8 +156,9 @@ export default function RegionalEditor({ open, nodes, initialNode, backgrounds,
return;
}
if (tool === "brush-subtract" && (!selectedLayerId || selectedLayer?.authoring.locked)) return;
const id = uuid(), layerId = tool === "brush-subtract" ? selectedLayerId! : id;
const authoring = tool === "brush-subtract" ? selectedLayer?.authoring : { name: `${tool === "rect" ? "Rectangle" : "Brush"} ${geometryLayers(selectedRegion).length + 1}`, visible: true, locked: false };
const id = uuid(), reusableBrushLayerId = tool === "brush-add" ? brushAddLayerTarget(selectedLayer) : null;
const layerId = tool === "brush-subtract" ? selectedLayerId! : reusableBrushLayerId ?? id;
const authoring = tool === "brush-subtract" || reusableBrushLayerId ? selectedLayer?.authoring : { name: `${tool === "rect" ? "Rectangle" : "Brush"} ${geometryLayers(selectedRegion).length + 1}`, visible: true, locked: false };
if (tool === "rect") {
setSelectedLayerId(layerId); setDraft([{ id, layer_id: layerId, type: "rect", operation: "add", enabled: true, authoring, x: point.x, y: point.y, width: .001, height: .001 }]); gesture.current = { mode: "draw-rect", start: point, commit: "add" };
} else {
@@ -190,18 +191,18 @@ export default function RegionalEditor({ open, nodes, initialNode, backgrounds,
const duplicateLayer = (id: string) => updateLayer(id, (operations, region) => { const layerId = uuid(), copies = moveLayer(clone(operations), .02, .02, documentValue!.canvas); copies.forEach((item, index) => { item.id = uuid(); item.layer_id = layerId; item.authoring = { ...geometryAuthoring(item), name: `${geometryAuthoring(item).name} Copy`, visible: true, locked: false }; if (index) item.authoring = copies[0].authoring; }); region.geometry.push(...copies); setSelectedLayerId(layerId); });
const deleteRegion = (id: string) => { const region = documentValue?.regions.find(item => item.id === id); if (!region || !window.confirm(`Delete region “${region.name}” with ${geometryLayers(region).length} layer(s)?`)) return; mutate(value => { value.regions = value.regions.filter(item => item.id !== id); value.regions.forEach(item => { if (item.parent_region_id === id) item.parent_region_id = null; }); syncPriorities(value.regions); }); setSelectedRegionId(documentValue?.regions.find(item => item.id !== id)?.id ?? null); setSelectedLayerId(null); };
const importDocument = async (file: File) => { try { const imported = parseDocument(await file.text()); imported.regions = regionsInPriorityOrder(imported.regions); syncPriorities(imported.regions); const collision = nodes.some(candidate => candidate !== node && (() => { try { return parseDocument(getWidget(candidate)?.value).document_id === imported.document_id; } catch { return false; } })()); if (collision) imported.document_id = uuid(); persist(imported); setSelectedRegionId(imported.regions[0]?.id ?? null); setSelectedLayerId(null); } catch (reason) { alert(`Import failed: ${reason}`); } };
const importDocument = async (file: File) => { try { if (!documentValue) return; const imported = preserveDocumentIdentity(parseDocument(await file.text()), documentValue.document_id); imported.regions = regionsInPriorityOrder(imported.regions); syncPriorities(imported.regions); persist(imported); setSelectedRegionId(imported.regions[0]?.id ?? null); setSelectedLayerId(null); } catch (reason) { alert(`Import failed: ${reason}`); } };
const importRegions = async (file: File) => { try { const data = JSON.parse(await file.text()); if (data?.schema !== "bv.regions" || data?.version !== 1 || !Array.isArray(data.regions)) throw new Error("Not a BV Regions v1 export"); mutate(value => { value.regions.push(...remapImportedRegions(data.regions)); syncPriorities(value.regions); }); } catch (reason) { alert(`Import failed: ${reason}`); } };
if (!open) return null;
const windowGeometry = activeWindowGeometry(viewState, { width: window.innerWidth, height: window.innerHeight });
return <div ref={shell} className={`bv-regional-shell ${viewState.mode}`} role="dialog" aria-modal={viewState.mode === "workspace"} style={{ inset: "auto", left: windowGeometry.x, top: windowGeometry.y, width: windowGeometry.width, height: windowGeometry.height }} onKeyDown={event => event.stopPropagation()}>
<header onPointerDown={beginWindowDrag}><EditorMenus openMenu={viewState.openMenu} onOpenMenu={updateOpenMenu} displayOpacity={displayOpacity} isolate={isolate} hasSelection={!!selectedLayerId} onDisplayOpacity={setDisplayOpacity} onToggleIsolate={() => setIsolate(value => !value)} onExportDocument={() => documentValue && downloadJson(`${documentValue.title || "bv-regional"}.json`, documentValue)} onExportRegions={() => documentValue && downloadJson(`${documentValue.title || "bv-regions"}.regions.json`, { schema: "bv.regions", version: 1, canvas: documentValue.canvas, regions: documentValue.regions })} onImportDocument={importDocument} onImportRegions={importRegions} onUndo={undo} onRedo={redo} canUndo={!!history.length} canRedo={!!future.length} canvas={documentValue?.canvas ?? { width: 1024, height: 1024 }} onCanvas={canvas => mutate(value => { value.canvas = canvas; })}/><div className="window-drag-handle"><span aria-hidden="true">⠿</span><strong>BV Regional Editor</strong></div><select value={node?.id ?? ""} onChange={event => setNode(nodes.find(item => String(item.id) === event.target.value) ?? null)}>{nodes.map(item => <option key={item.id} value={String(item.id)}>{`${item.title || "BV Regional Prompt"} · #${item.id}`}</option>)}</select><button className="window-mode-button" title={viewState.mode === "workspace" ? "Switch to floating window" : "Switch to workspace view"} onClick={() => setViewState(current => ({ ...current, mode: current.mode === "workspace" ? "floating" : "workspace" }))}>{viewState.mode === "workspace" ? "Float" : "Workspace"}</button><button title="Close" onClick={onClose}>×</button></header>
<header onPointerDown={beginWindowDrag}><EditorMenus openMenu={viewState.openMenu} onOpenMenu={updateOpenMenu} displayOpacity={displayOpacity} backgroundOpacity={backgroundOpacity} isolate={isolate} hasSelection={!!selectedLayerId} onDisplayOpacity={setDisplayOpacity} onBackgroundOpacity={setBackgroundOpacity} onToggleIsolate={() => setIsolate(value => !value)} onExportDocument={() => documentValue && downloadJson(`${documentValue.title || "bv-regional"}.json`, documentValue)} onExportRegions={() => documentValue && downloadJson(`${documentValue.title || "bv-regions"}.regions.json`, { schema: "bv.regions", version: 1, canvas: documentValue.canvas, regions: documentValue.regions })} onImportDocument={importDocument} onImportRegions={importRegions} onUndo={undo} onRedo={redo} canUndo={!!history.length} canRedo={!!future.length} canvas={documentValue?.canvas ?? { width: 1024, height: 1024 }} onCanvas={canvas => mutate(value => { value.canvas = canvas; })}/><div className="window-drag-handle"><span aria-hidden="true">⠿</span><strong>BV Regional Editor</strong></div><select value={node?.id ?? ""} onChange={event => setNode(nodes.find(item => String(item.id) === event.target.value) ?? null)}>{nodes.map(item => <option key={item.id} value={String(item.id)}>{`${item.title || "BV Regional Prompt"} · #${item.id}`}</option>)}</select><button className="window-mode-button" title={viewState.mode === "workspace" ? "Switch to floating window" : "Switch to workspace view"} onClick={() => setViewState(current => ({ ...current, mode: current.mode === "workspace" ? "floating" : "workspace" }))}>{viewState.mode === "workspace" ? "Float" : "Workspace"}</button><button title="Close" onClick={onClose}>×</button></header>
{error ? <div className="bv-regional-error">{error}</div> : documentValue && <>
<main style={{ gridTemplateColumns: `${viewState.panels.left}px 6px minmax(300px, 1fr) 6px ${viewState.panels.right}px` }}>
<LayerPanel regions={documentValue.regions} selectedRegionId={selectedRegionId} selectedLayerId={selectedLayerId} isolate={isolate} onSelectRegion={id => { setSelectedRegionId(id); setSelectedLayerId(null); setIsolate(false); }} onSelectLayer={setSelectedLayerId} onAddRegion={() => mutate(value => { const region = newRegion(value.regions.length); value.regions.unshift(region); syncPriorities(value.regions); setSelectedRegionId(region.id); setSelectedLayerId(null); })} onRenameRegion={(id, name) => mutate(value => { value.regions.find(region => region.id === id)!.name = name; })} onRenameLayer={(id, name) => updateLayer(id, operations => operations.forEach(item => { item.authoring = { ...geometryAuthoring(item), name }; }))} onToggleRegion={(id, field) => mutate(value => { const region = value.regions.find(item => item.id === id)!; if (field === "enabled") region.enabled = !region.enabled; else region.authoring.locked = !region.authoring.locked; })} onToggleLayer={toggleLayer} onMoveRegion={moveRegion} onMoveLayer={moveLayerOrder} onDeleteRegion={deleteRegion} onDeleteLayer={deleteLayer} onDuplicateLayer={duplicateLayer} onToggleIsolate={() => setIsolate(value => !value)}/>
<div className="panel-splitter" onPointerDown={event => beginPanelResize("left", event)} onDoubleClick={() => setViewState(current => ({ ...current, panels: { ...current.panels, left: 290 } }))}/>
<Artboard key={documentValue.document_id} document={documentValue} background={backgrounds[documentValue.document_id]} selectedRegionId={selectedRegionId} selectedLayerId={selectedLayerId} draft={draft} selectionBounds={selectionBounds} cursor={cursor} tool={tool} brush={brush} canSubtract={!!selectedLayer && !selectedLayer.authoring.locked} displayOpacity={displayOpacity} isolate={isolate} initialView={viewState.artboard} onView={updateArtboardView} onTool={next => { setTool(next); cancelGesture(); }} onBrush={setBrush} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerUp} onPointerCancel={cancelGesture} onPointerLeave={() => { if (!gesture.current) setCursor(null); }}/>
<Artboard key={documentValue.document_id} document={documentValue} background={backgrounds[documentValue.document_id]} selectedRegionId={selectedRegionId} selectedLayerId={selectedLayerId} draft={draft} selectionBounds={selectionBounds} cursor={cursor} tool={tool} brush={brush} canSubtract={!!selectedLayer && !selectedLayer.authoring.locked} displayOpacity={displayOpacity} backgroundOpacity={backgroundOpacity} isolate={isolate} initialView={viewState.artboard} onView={updateArtboardView} onTool={next => { setTool(next); cancelGesture(); }} onBrush={setBrush} onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={pointerUp} onPointerCancel={cancelGesture} onPointerLeave={() => { if (!gesture.current) setCursor(null); }}/>
<div className="panel-splitter" onPointerDown={event => beginPanelResize("right", event)} onDoubleClick={() => setViewState(current => ({ ...current, panels: { ...current.panels, right: 340 } }))}/>
<OptionsPanel region={selectedRegion} layer={selectedLayer} bounds={selectionBounds} canvas={documentValue.canvas} globalPrompts={documentValue.prompts.global} backgroundPrompts={documentValue.prompts.background} negativeMode={documentValue.negative_mode} promptSections={viewState.promptSections} onPromptSection={(section, open) => setViewState(current => ({ ...current, promptSections: { ...current.promptSections, [section]: open } }))} onNegativeMode={negativeMode => mutate(value => { value.negative_mode = negativeMode; })} onGlobalPrompts={prompts => mutate(value => { value.prompts.global = prompts; })} onBackgroundPrompts={prompts => mutate(value => { value.prompts.background = prompts; })} onRegion={updateRegion} onLayerBounds={bounds => selectedLayerId && updateLayer(selectedLayerId, (operations, region) => { const replacements = new Map(setLayerBounds(operations, bounds, documentValue.canvas).map(item => [item.id, item])); region.geometry = region.geometry.map(item => replacements.get(item.id) ?? item); })} onBrushSetting={(field, setting) => selectedLayerId && updateLayer(selectedLayerId, operations => operations.forEach(item => { if (item.type === "brush_stroke") Object.assign(item, { [field]: setting }); }))}/>
</main>
+3
View File
@@ -13,6 +13,7 @@ export type EditorViewState = {
selectedRegionId: string | null;
selectedLayerId: string | null;
displayOpacity: number;
backgroundOpacity: number;
isolate: boolean;
tool: Tool;
brush: BrushSettings;
@@ -83,6 +84,7 @@ export function defaultEditorState(viewport = { width: window.innerWidth, height
selectedRegionId: null,
selectedLayerId: null,
displayOpacity: .5,
backgroundOpacity: 1,
isolate: false,
tool: "select",
brush: { size: .04, hardness: .75, opacity: 1, shape: "round", pressureMode: "constant" },
@@ -117,6 +119,7 @@ export function normalizeEditorState(value: unknown, viewport = { width: window.
selectedRegionId: typeof input.selectedRegionId === "string" ? input.selectedRegionId : null,
selectedLayerId: typeof input.selectedLayerId === "string" ? input.selectedLayerId : null,
displayOpacity: clamp(finite(input.displayOpacity, .5), .05, 1),
backgroundOpacity: clamp(finite(input.backgroundOpacity, 1), 0, 1),
isolate: input.isolate === true,
tool: tools.includes(input.tool as Tool) ? input.tool as Tool : "select",
brush: {
+11
View File
@@ -1,4 +1,15 @@
export type ScreenPoint = { x: number; y: number };
type BrushLayerCandidate = {
id: string;
enabled: boolean;
authoring: { locked: boolean };
geometries: Array<{ type: string }>;
};
export function brushAddLayerTarget(layer: BrushLayerCandidate | null) {
if (!layer || !layer.enabled || layer.authoring.locked) return null;
return layer.geometries.some(geometry => geometry.type === "brush_stroke") ? layer.id : null;
}
export function shouldStartSelectionMove(start: ScreenPoint, current: ScreenPoint, threshold = 3) {
return Math.hypot(current.x - start.x, current.y - start.y) >= threshold;
+3
View File
@@ -33,6 +33,9 @@ export const newRegion = (index: number): Region => ({
authoring: { visible: true, locked: false, color: COLORS[index % COLORS.length] },
});
export const clone = <T,>(value: T): T => structuredClone(value);
export function preserveDocumentIdentity<T extends { document_id: string }>(imported: T, documentId: string): T {
return { ...imported, document_id: documentId };
}
export const geometryAuthoring = (geometry: Geometry, index = 0): GeometryAuthoring => geometry.authoring ?? { name: `${geometry.type === "rect" ? "Rectangle" : "Brush"} ${index + 1}`, visible: true, locked: false };
export const geometryLayerId = (geometry: Geometry) => geometry.layer_id ?? geometry.id;
export function geometryLayers(region: Region): GeometryLayer[] {