mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835d94a11d | ||
|
|
061e2b7a9a | ||
|
|
b1f29eefdb | ||
|
|
b8fbcee67a | ||
|
|
d44d944f2d | ||
|
|
ab5d71597a | ||
|
|
ce4d332987 | ||
|
|
9b04729561 | ||
|
|
27ad139cd5 | ||
|
|
66cbcb641b | ||
|
|
986e0a23a2 | ||
|
|
068ed9ee59 | ||
|
|
4e5ef18d93 | ||
|
|
be37966b45 | ||
|
|
dd5fc5470f | ||
|
|
1f1d0aeb7d | ||
|
|
da55d741d6 | ||
|
|
959c47c29b | ||
|
|
ab7ab9d1a8 | ||
|
|
d8d33089d2 | ||
|
|
de67252a87 | ||
|
|
4acece1602 | ||
|
|
ffa5b136bf | ||
|
|
7a5ecb3919 | ||
|
|
20ab861315 | ||
|
|
6750141bcc | ||
|
|
5ea2562b32 | ||
|
|
079fb7b362 | ||
|
|
e05e2d8d8a | ||
|
|
ae55c8a827 | ||
|
|
e21fab0061 | ||
|
|
36a80bbb7e | ||
|
|
492e06068a | ||
|
|
9af1491c68 | ||
|
|
b04795d6e8 | ||
|
|
8d1545bb7e | ||
|
|
f6a240c535 | ||
|
|
d1ceb6291b | ||
|
|
868221b285 | ||
|
|
0f4f2cb1b0 | ||
|
|
7ce7194cbf | ||
|
|
990853f8c7 | ||
|
|
5fb163cd59 | ||
|
|
19d3238680 | ||
|
|
c9860cac9e | ||
|
|
00cf74a3c2 | ||
|
|
00a39d756d | ||
|
|
d0e6bf8b3d | ||
|
|
da37900b33 | ||
|
|
64c5e49707 | ||
|
|
06d94f6a63 | ||
|
|
b21d6e3502 | ||
|
|
285ad035b2 | ||
|
|
949ffa0143 | ||
|
|
afdac52144 | ||
|
|
bf55d13f67 | ||
|
|
de83a884c2 | ||
|
|
dd2a81b6f2 | ||
|
|
176b9d03ac | ||
|
|
e4f44c10e8 | ||
|
|
11dd554204 | ||
|
|
9f21ff13ae | ||
|
|
1a1d8748cb | ||
|
|
38973b4698 | ||
|
|
1bd261bee0 | ||
|
|
df6979a59b | ||
|
|
2427f0bc5f | ||
|
|
3356c631bb | ||
|
|
3d34bfafd5 | ||
|
|
3c3e6934d7 | ||
|
|
84e1e4820c | ||
|
|
012368c52b | ||
|
|
82c42f99fe | ||
|
|
5da0855a52 | ||
|
|
ed9fdf5d60 | ||
|
|
d84b9385ad | ||
|
|
c4318d4923 | ||
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 | ||
|
|
9b0d4b3149 | ||
|
|
f0f3d419f8 | ||
|
|
26e2036388 | ||
|
|
22f5d028a2 |
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,20 +1,75 @@
|
|||||||
name: 🐞 Bug Report
|
name: 🐞 Bug Report
|
||||||
description: Report an error or unexpected behavior
|
description: 'Report something that is not working correctly'
|
||||||
title: "[BUG] "
|
title: "[BUG] "
|
||||||
labels: [bug]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Prerequisites
|
||||||
|
options:
|
||||||
|
- label: I am running the latest version of [ComfyUI](https://github.com/comfyanonymous/ComfyUI/releases)
|
||||||
|
required: true
|
||||||
|
- label: I am running the latest version of [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend/releases)
|
||||||
|
required: true
|
||||||
|
- label: I am running the latest version of LayerForge [Github](https://github.com/Azornes/Comfyui-LayerForge/releases) | [Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
||||||
|
required: true
|
||||||
|
- label: I have searched existing(open/closed) issues to make sure this isn't a duplicate
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: A clear and concise description of the bug. Include screenshots or videos if helpful.
|
||||||
|
placeholder: |
|
||||||
|
Example: "When I connect a image to an Input, the connection line appears but the workflow fails to execute with an error message..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: How can I reproduce this issue? Please attach your workflow (JSON or PNG) if needed.
|
||||||
|
placeholder: |
|
||||||
|
1. Connect Image to Input
|
||||||
|
2. Click Queue Prompt
|
||||||
|
3. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: How is this affecting you?
|
||||||
|
options:
|
||||||
|
- Crashes ComfyUI completely
|
||||||
|
- Workflow won't execute
|
||||||
|
- Feature doesn't work as expected
|
||||||
|
- Visual/UI issue only
|
||||||
|
- Minor inconvenience
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser
|
||||||
|
description: Which browser are you using?
|
||||||
|
options:
|
||||||
|
- Chrome/Chromium
|
||||||
|
- Firefox
|
||||||
|
- Safari
|
||||||
|
- Edge
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Thank you for reporting a bug!**
|
## Additional Information (Optional)
|
||||||
Please follow these steps to capture all necessary information:
|
*The following fields help me debug complex issues but are not required for most bug reports.*
|
||||||
|
|
||||||
### ✅ Before You Report:
|
|
||||||
1. Make sure you have the **latest versions**:
|
|
||||||
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
|
|
||||||
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
|
||||||
2. Gather the required logs:
|
|
||||||
|
|
||||||
### 🔍 Enable Debug Logs (for **full** logs):
|
### 🔍 Enable Debug Logs (for **full** logs):
|
||||||
|
|
||||||
#### 1. Edit `config.js` (Frontend Logs):
|
#### 1. Edit `config.js` (Frontend Logs):
|
||||||
@@ -46,75 +101,13 @@ body:
|
|||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
|
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: environment
|
|
||||||
attributes:
|
|
||||||
label: Environment (OS, ComfyUI version, LayerForge version)
|
|
||||||
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: browser
|
|
||||||
attributes:
|
|
||||||
label: Browser & Version
|
|
||||||
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what_happened
|
id: console-errors
|
||||||
attributes:
|
attributes:
|
||||||
label: What Happened?
|
label: Console Errors
|
||||||
placeholder: Describe the issue you encountered
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to Reproduce
|
|
||||||
placeholder: |
|
|
||||||
1. …
|
|
||||||
2. …
|
|
||||||
3. …
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected Behavior
|
|
||||||
placeholder: Describe what you expected to happen
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: Actual Behavior
|
|
||||||
placeholder: Describe what happened instead
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: backend_logs
|
|
||||||
attributes:
|
|
||||||
label: ComfyUI (Backend) Logs
|
|
||||||
description: |
|
|
||||||
After enabling DEBUG logs, please:
|
|
||||||
1. Restart ComfyUI.
|
|
||||||
2. Reproduce the issue.
|
|
||||||
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: console_logs
|
|
||||||
attributes:
|
|
||||||
label: Browser Console Logs
|
|
||||||
description: |
|
description: |
|
||||||
|
If you see red error messages in the browser console (F12), paste them here
|
||||||
|
More info:
|
||||||
After enabling DEBUG logs:
|
After enabling DEBUG logs:
|
||||||
1. Open Developer Tools → Console.
|
1. Open Developer Tools → Console.
|
||||||
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
||||||
@@ -128,11 +121,25 @@ body:
|
|||||||
- Safari: 🗑 icon or `Cmd+K`.
|
- Safari: 🗑 icon or `Cmd+K`.
|
||||||
3. Reproduce the issue.
|
3. Reproduce the issue.
|
||||||
4. Copy-paste the **TEXT** logs here (no screenshots).
|
4. Copy-paste the **TEXT** logs here (no screenshots).
|
||||||
validations:
|
render: javascript
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: markdown
|
- type: textarea
|
||||||
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
label: Logs
|
||||||
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
description: |
|
||||||
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
If relevant, paste any terminal/server logs here
|
||||||
|
More info:
|
||||||
|
After enabling DEBUG logs, please:
|
||||||
|
1. Restart ComfyUI.
|
||||||
|
2. Reproduce the issue.
|
||||||
|
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context, Environment (OS, ComfyUI versions, Comfyui-LayerForge version)
|
||||||
|
description: Any other information that might help (OS, GPU, specific nodes involved, etc.)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
@@ -3,11 +3,17 @@ description: Suggest improvements or additions to documentation
|
|||||||
title: "[Docs] "
|
title: "[Docs] "
|
||||||
labels: [documentation]
|
labels: [documentation]
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
> This template is only for suggesting improvements or additions **to existing documentation**.
|
||||||
|
> If you want to suggest a new feature, functionality, or enhancement for the project itself, please use the **Feature Request** template instead.
|
||||||
|
> Thank you!
|
||||||
- type: input
|
- type: input
|
||||||
id: doc_area
|
id: doc_area
|
||||||
attributes:
|
attributes:
|
||||||
label: Area of documentation
|
label: Area of documentation
|
||||||
placeholder: e.g. Getting started, Node API, Deployment guide
|
placeholder: e.g. Key Features, Installation, Controls & Shortcuts
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
16
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
16
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -3,6 +3,22 @@ description: Suggest an idea for this project
|
|||||||
title: '[Feature Request]: '
|
title: '[Feature Request]: '
|
||||||
labels: ['enhancement']
|
labels: ['enhancement']
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Before suggesting a new feature...
|
||||||
|
Please make sure of the following:
|
||||||
|
|
||||||
|
1. You are using the latest version of the project
|
||||||
|
2. The functionality you want to propose does not already exist
|
||||||
|
|
||||||
|
I also recommend using an AI assistant to check whether the feature is already included.
|
||||||
|
To do this, simply:
|
||||||
|
|
||||||
|
- Copy and paste the entire **README.md** file
|
||||||
|
- Ask if your desired feature is already covered
|
||||||
|
|
||||||
|
This helps to avoid duplicate requests for features that are already available.
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
|
|||||||
2
.github/workflows/ComfyUIdownloads.yml
vendored
2
.github/workflows/ComfyUIdownloads.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
max_downloads=0
|
max_downloads=0
|
||||||
top_node_json="{}"
|
top_node_json="{}"
|
||||||
|
|
||||||
for i in {1..20}; do
|
for i in {1..3}; do
|
||||||
echo "Pobieranie danych z próby $i..."
|
echo "Pobieranie danych z próby $i..."
|
||||||
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json
|
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json
|
||||||
|
|
||||||
|
|||||||
136
README.md
136
README.md
@@ -19,6 +19,15 @@
|
|||||||
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
|
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>🔹 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-installation">Quick Start</a></strong>
|
||||||
|
|
|
||||||
|
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
|
||||||
|
|
|
||||||
|
<strong>⚠️ <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#%EF%B8%8F-known-issues--compatibility">Known Issues</a></strong>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
### Why LayerForge?
|
### Why LayerForge?
|
||||||
|
|
||||||
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
||||||
@@ -28,13 +37,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
|
https://github.com/user-attachments/assets/90fffb9a-dae2-4d19-aca2-5d47600f0a01
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
- **Freeform Inpainting Area:** Draw any custom (non-rectangular) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
- **Freeform Inpainting Area:** Draw any custom (like a polygonal lasso tool) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
||||||
- **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas
|
- **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas
|
||||||
state (layers, positions, etc.) even after a page reload.
|
state (layers, positions, etc.) even after a page reload.
|
||||||
- **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering.
|
- **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering.
|
||||||
@@ -51,19 +60,27 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
|||||||
- **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model.
|
- **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model.
|
||||||
- **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
|
- **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
|
||||||
browser's storage footprint low.
|
browser's storage footprint low.
|
||||||
- **Workflow Integration:** Outputs a final composite **image** and a combined alpha **mask**, ready for any other
|
- **Inputs**
|
||||||
ComfyUI node.
|
- **Image Input (optional):** Accepts a single image.
|
||||||
|
- **Multiple Images:** If you need to feed in more than one image, use the **core ComfyUI Batch Image node**.
|
||||||
|
- This lets you route multiple images into LayerForge.
|
||||||
|
- You can then stack, arrange, and edit them as separate layers inside the canvas.
|
||||||
|
- **Mask Input (optional):** Accepts a single external mask.
|
||||||
|
- When provided, the mask is applied directly to the **output area** of the LayerForge canvas when `Run` is triggered in ComfyUI.
|
||||||
|
- **Outputs**
|
||||||
|
- **Composite Image:** The final flattened result of your layer stack.
|
||||||
|
- **Combined Alpha Mask:** A merged mask representing all visible layers, ready for downstream nodes.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation
|
## 🚀 Installation
|
||||||
|
|
||||||
### Install via ComfyUI-Manager
|
### Install via ComfyUI-Manager
|
||||||
* Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button.
|
1. Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button.
|
||||||
|
2. Restart ComfyUI.
|
||||||
|
|
||||||
### Manual Install
|
### Manual Install
|
||||||
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
|
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). I use [portable](https://docs.comfy.org/installation/comfyui_portable_windows) version.
|
||||||
2. Clone this repo into `custom_modules`:
|
2. Clone this repo into `custom_nodes`:
|
||||||
```bash
|
```bash
|
||||||
cd ComfyUI/custom_nodes/
|
cd ComfyUI/custom_nodes/
|
||||||
git clone https://github.com/Azornes/Comfyui-LayerForge.git
|
git clone https://github.com/Azornes/Comfyui-LayerForge.git
|
||||||
@@ -71,16 +88,67 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
|||||||
3. Start up ComfyUI.
|
3. Start up ComfyUI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🎯 Polygonal Lasso Inpainting Workflow
|
||||||
|
|
||||||
|
LayerForge's newest feature allows you to draw custom polygonal selection areas and run inpainting directly within ComfyUI. This brings Photoshop-like lasso tool functionality to your AI workflows.
|
||||||
|
|
||||||
|
### Setup Requirements
|
||||||
|
|
||||||
|
1. **Enable Auto-Refresh:** In LayerForge's settings, enable `auto_refresh_after_generation`. Without this setting, the new generation output won't update automatically in the canvas.
|
||||||
|
|
||||||
|
2. **Configure Auto-Apply (Optional):** If you want the mask to be automatically applied after drawing the shape, enable the `auto-apply shape mask` option in the Custom Output Area menu (appears on the left when a custom shape is active).
|
||||||
|
|
||||||
|
### How to Use Polygonal Selection
|
||||||
|
|
||||||
|
1. **Start Drawing:** Hold `Shift + S` and left-click to place the first point of your polygonal selection.
|
||||||
|
|
||||||
|
2. **Add Points:** Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.
|
||||||
|
|
||||||
|
3. **Close Selection:** Click back on the first point (or close to it) to complete and close the polygonal selection.
|
||||||
|
|
||||||
|
4. **Run Inpainting:** Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image.
|
||||||
|
|
||||||
|
### Advanced Shape Mask Options
|
||||||
|
|
||||||
|
When using custom shapes, LayerForge provides several options to fine-tune the mask quality:
|
||||||
|
|
||||||
|
- **Mask Expansion/Contraction:** Adjust the mask boundary by -300 to +300 pixels to ensure better blending
|
||||||
|
- **Edge Feathering:** Apply 0-300px feathering to create smooth transitions and reduce visible seams
|
||||||
|
- **Output Area Extension:** Extend the output area in all directions for more context during generation
|
||||||
|
- **Manual Blend Menu:** Right-click to access manual color adjustment tools for perfect edge blending
|
||||||
|
|
||||||
|
### Tips for Best Results
|
||||||
|
|
||||||
|
* Use **feathering (10–50px)** depending on the **size of the image** to create smooth transitions between the inpainted area and existing content. Larger images generally benefit from more feathering.
|
||||||
|
* Experiment with **mask expansion** (e.g., 10–20px) if you notice hard edges or visible seams.
|
||||||
|
* Use **Output Area Extension** based on image size:
|
||||||
|
|
||||||
|
* **Extend the output area in all directions** to give the model more **context during generation**, especially for larger or more complex images.
|
||||||
|
* If **visible seams** still appear in the inpainting results:
|
||||||
|
|
||||||
|
* Use the **Manual Blend Menu** (right-click on the mask area) to access **color and edge adjustment tools** for precise fine-tuning and seamless integration.
|
||||||
|
* **Image placement behavior:**
|
||||||
|
|
||||||
|
* The generated or pasted image is automatically inserted into the area defined by the **blue shape** you draw.
|
||||||
|
* The model uses the area within the **dashed white preview outline** as the **full context** during generation.
|
||||||
|
* Make sure the dashed region covers enough surrounding content to preserve lighting, texture, and scene coherence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧪 Workflow Example
|
## 🧪 Workflow Example
|
||||||
|
|
||||||
For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node.
|
For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node.
|
||||||
|
|
||||||
**🔗 Download Example Workflow**
|
**🔗 Download Example Workflow**
|
||||||
|
|
||||||

|
### 🔹 Simple Test Workflow
|
||||||
|
This workflow allows **quick testing** of node behavior and output structures **without requiring additional models or complex dependencies**. Useful for inspecting how basic outputs are generated and connected.
|
||||||
|

|
||||||
|
|
||||||
|
### 🔹 Flux Inpainting Workflow
|
||||||

|
This example shows a typical **inpainting setup using the Flux model**. It demonstrates how to integrate model-based fill with contextual generation for seamless content restoration.
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +157,6 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## 🎮 Controls & Shortcuts
|
## 🎮 Controls & Shortcuts
|
||||||
|
|
||||||
### Canvas Control
|
### Canvas Control
|
||||||
@@ -100,7 +167,7 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
|||||||
| `Mouse Wheel` | Zoom view in/out |
|
| `Mouse Wheel` | Zoom view in/out |
|
||||||
| `Shift + Click (background)` | Start resizing canvas area |
|
| `Shift + Click (background)` | Start resizing canvas area |
|
||||||
| `Shift + Ctrl + Click` | Start moving entire canvas |
|
| `Shift + Ctrl + Click` | Start moving entire canvas |
|
||||||
| `Shift + S + Left Click` | Draw custom shape for output area |
|
| `Shift + S + Left Click` | Draw custom polygonal shape for output area |
|
||||||
| `Single Click (background)` | Deselect all layers |
|
| `Single Click (background)` | Deselect all layers |
|
||||||
| `Esc` | Close fullscreen editor mode |
|
| `Esc` | Close fullscreen editor mode |
|
||||||
| `Double Click (background)` | Deselect all layers |
|
| `Double Click (background)` | Deselect all layers |
|
||||||
@@ -151,6 +218,14 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
|||||||
| **Clear Mask** | Remove the entire mask |
|
| **Clear Mask** | Remove the entire mask |
|
||||||
| **Exit Mode** | Click the "Draw Mask" button again |
|
| **Exit Mode** | Click the "Draw Mask" button again |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Model Compatibility
|
||||||
|
|
||||||
|
LayerForge is designed to work with **any ComfyUI-compatible model**. The node outputs standard image and mask data that can be used with any model or workflow. LayerForge automatically inserts the generated image into the exact shape and position you draw with the blue polygon tool — but only if the generated image is saved properly, for example via a Save Image node.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧠 Optional: Matting Model (for image cutout)
|
## 🧠 Optional: Matting Model (for image cutout)
|
||||||
|
|
||||||
The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an
|
The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an
|
||||||
@@ -165,17 +240,24 @@ optional feature and requires a model.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐞 Known Issue:
|
## ⚠️ Known Issues / Compatibility
|
||||||
### `node_id` not auto-filled → black output
|
|
||||||
|
|
||||||
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
#### ○ Incompatibility with Modern Node Design (Vue Nodes)
|
||||||
As a result, the node may produce a **completely black image** or not work at all.
|
> This node is **not compatible** with the new Vue Nodes display system.
|
||||||
|
>
|
||||||
|
> 🔧 **How to fix:**
|
||||||
|
> Go to **Settings → (search) "Vue Nodes" → Disable "Modern Node Design (Vue Nodes)"**.
|
||||||
|
|
||||||
**Workaround:**
|
---
|
||||||
|
|
||||||
* Search node ID in ComfyUI settings.
|
#### ○ `node_id` not auto-filled → black output
|
||||||
* In NodesMap check "Enable node ID display"
|
> In some cases, **ComfyUI doesn’t auto-fill the `node_id`** when adding a node.
|
||||||
* Manually enter the correct `node_id` (match the ID Node "LayerForge" shown above the node, on the right side).
|
> This may cause the node to output a **completely black image** or fail to work.
|
||||||
|
>
|
||||||
|
> 🛠️ **Workaround:**
|
||||||
|
> - Open **Settings → NodesMap → Enable "Show node IDs"**
|
||||||
|
> - Find the correct ID for your node *(match the ID Node "LayerForge" shown above the node, on the right side)*.
|
||||||
|
> - Manually enter the correct `node_id` in the LayerForge node
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This is a known issue and not yet fixed.
|
> This is a known issue and not yet fixed.
|
||||||
@@ -189,5 +271,17 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 💖 Support / Sponsorship
|
||||||
|
• ⭐ Give a star — it means a lot to me!
|
||||||
|
• 🐛 Report a bug or suggest a feature
|
||||||
|
• 💖 If you’d like to support my work:
|
||||||
|
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
|
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
|
||||||
significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI.
|
significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI.
|
||||||
|
|
||||||
|
Special thanks to the ComfyUI community for feedback, bug reports, and feature suggestions that help make LayerForge better.
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import os
|
|||||||
# Add the custom node's directory to the Python path
|
# Add the custom node's directory to the Python path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from .canvas_node import CanvasNode
|
from .canvas_node import LayerForgeNode
|
||||||
|
|
||||||
CanvasNode.setup_routes()
|
LayerForgeNode.setup_routes()
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"CanvasNode": CanvasNode
|
"LayerForgeNode": LayerForgeNode
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
"LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
WEB_DIRECTORY = "./js"
|
||||||
|
|||||||
259
canvas_node.py
259
canvas_node.py
@@ -64,6 +64,8 @@ class BiRefNetConfig(PretrainedConfig):
|
|||||||
|
|
||||||
def __init__(self, bb_pretrained=False, **kwargs):
|
def __init__(self, bb_pretrained=False, **kwargs):
|
||||||
self.bb_pretrained = bb_pretrained
|
self.bb_pretrained = bb_pretrained
|
||||||
|
# Add the missing is_encoder_decoder attribute for compatibility with newer transformers
|
||||||
|
self.is_encoder_decoder = False
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ class BiRefNet(torch.nn.Module):
|
|||||||
return [output]
|
return [output]
|
||||||
|
|
||||||
|
|
||||||
class CanvasNode:
|
class LayerForgeNode:
|
||||||
_canvas_data_storage = {}
|
_canvas_data_storage = {}
|
||||||
_storage_lock = threading.Lock()
|
_storage_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -179,6 +181,10 @@ class CanvasNode:
|
|||||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||||
"node_id": ("STRING", {"default": "0"}),
|
"node_id": ("STRING", {"default": "0"}),
|
||||||
},
|
},
|
||||||
|
"optional": {
|
||||||
|
"input_image": ("IMAGE",),
|
||||||
|
"input_mask": ("MASK",),
|
||||||
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"prompt": ("PROMPT",),
|
"prompt": ("PROMPT",),
|
||||||
"unique_id": ("UNIQUE_ID",),
|
"unique_id": ("UNIQUE_ID",),
|
||||||
@@ -239,7 +245,7 @@ class CanvasNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_processing_lock = threading.Lock()
|
||||||
|
|
||||||
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, input_image=None, input_mask=None, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -250,6 +256,81 @@ class CanvasNode:
|
|||||||
|
|
||||||
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
|
||||||
|
|
||||||
|
# Always store fresh input data, even if None, to clear stale data
|
||||||
|
log_info(f"Storing input data for node {node_id} - Image: {input_image is not None}, Mask: {input_mask is not None}")
|
||||||
|
|
||||||
|
with self.__class__._storage_lock:
|
||||||
|
input_data = {}
|
||||||
|
|
||||||
|
if input_image is not None:
|
||||||
|
# Convert image tensor(s) to base64 - handle batch
|
||||||
|
if isinstance(input_image, torch.Tensor):
|
||||||
|
# Ensure correct shape [B, H, W, C]
|
||||||
|
if input_image.dim() == 3:
|
||||||
|
input_image = input_image.unsqueeze(0)
|
||||||
|
|
||||||
|
batch_size = input_image.shape[0]
|
||||||
|
log_info(f"Processing batch of {batch_size} image(s)")
|
||||||
|
|
||||||
|
if batch_size == 1:
|
||||||
|
# Single image - keep backward compatibility
|
||||||
|
img_np = (input_image.squeeze(0).cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
input_data['input_image'] = f"data:image/png;base64,{img_str}"
|
||||||
|
input_data['input_image_width'] = pil_img.width
|
||||||
|
input_data['input_image_height'] = pil_img.height
|
||||||
|
log_debug(f"Stored single input image: {pil_img.width}x{pil_img.height}")
|
||||||
|
else:
|
||||||
|
# Multiple images - store as array
|
||||||
|
images_array = []
|
||||||
|
for i in range(batch_size):
|
||||||
|
img_np = (input_image[i].cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_img = Image.fromarray(img_np, 'RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
pil_img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
|
images_array.append({
|
||||||
|
'data': f"data:image/png;base64,{img_str}",
|
||||||
|
'width': pil_img.width,
|
||||||
|
'height': pil_img.height
|
||||||
|
})
|
||||||
|
log_debug(f"Stored batch image {i+1}/{batch_size}: {pil_img.width}x{pil_img.height}")
|
||||||
|
|
||||||
|
input_data['input_images_batch'] = images_array
|
||||||
|
log_info(f"Stored batch of {batch_size} images")
|
||||||
|
|
||||||
|
if input_mask is not None:
|
||||||
|
# Convert mask tensor to base64
|
||||||
|
if isinstance(input_mask, torch.Tensor):
|
||||||
|
# Ensure correct shape
|
||||||
|
if input_mask.dim() == 2:
|
||||||
|
input_mask = input_mask.unsqueeze(0)
|
||||||
|
if input_mask.dim() == 3 and input_mask.shape[0] == 1:
|
||||||
|
input_mask = input_mask.squeeze(0)
|
||||||
|
|
||||||
|
# Convert to numpy and then to PIL
|
||||||
|
mask_np = (input_mask.cpu().numpy() * 255).astype(np.uint8)
|
||||||
|
pil_mask = Image.fromarray(mask_np, 'L')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
mask_buffered = io.BytesIO()
|
||||||
|
pil_mask.save(mask_buffered, format="PNG")
|
||||||
|
mask_str = base64.b64encode(mask_buffered.getvalue()).decode()
|
||||||
|
input_data['input_mask'] = f"data:image/png;base64,{mask_str}"
|
||||||
|
log_debug(f"Stored input mask: {pil_mask.width}x{pil_mask.height}")
|
||||||
|
|
||||||
|
input_data['fit_on_add'] = fit_on_add
|
||||||
|
|
||||||
|
# Store in a special key for input data (overwrites any previous data)
|
||||||
|
self.__class__._canvas_data_storage[f"{node_id}_input"] = input_data
|
||||||
|
|
||||||
storage_key = node_id
|
storage_key = node_id
|
||||||
|
|
||||||
processed_image = None
|
processed_image = None
|
||||||
@@ -433,6 +514,63 @@ class CanvasNode:
|
|||||||
log_info("WebSocket connection closed")
|
log_info("WebSocket connection closed")
|
||||||
return ws
|
return ws
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/layerforge/get_input_data/{node_id}")
|
||||||
|
async def get_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_debug(f"Checking for input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
input_data = cls._canvas_data_storage.get(input_key, None)
|
||||||
|
|
||||||
|
if input_data:
|
||||||
|
log_info(f"Input data found for node {node_id}, sending to frontend")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': True,
|
||||||
|
'data': input_data
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data found for node {node_id}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'has_input': False
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in get_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.post("/layerforge/clear_input_data/{node_id}")
|
||||||
|
async def clear_input_data(request):
|
||||||
|
try:
|
||||||
|
node_id = request.match_info["node_id"]
|
||||||
|
log_info(f"Clearing input data for node: {node_id}")
|
||||||
|
|
||||||
|
with cls._storage_lock:
|
||||||
|
input_key = f"{node_id}_input"
|
||||||
|
if input_key in cls._canvas_data_storage:
|
||||||
|
del cls._canvas_data_storage[input_key]
|
||||||
|
log_info(f"Input data cleared for node {node_id}")
|
||||||
|
else:
|
||||||
|
log_debug(f"No input data to clear for node {node_id}")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Input data cleared for node {node_id}'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in clear_input_data: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
|
||||||
async def get_canvas_data(request):
|
async def get_canvas_data(request):
|
||||||
try:
|
try:
|
||||||
@@ -619,16 +757,32 @@ class BiRefNetMatting:
|
|||||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
||||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
||||||
try:
|
try:
|
||||||
|
# Try loading with additional configuration to handle compatibility issues
|
||||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||||
"ZhengPeng7/BiRefNet",
|
"ZhengPeng7/BiRefNet",
|
||||||
trust_remote_code=True,
|
trust_remote_code=True,
|
||||||
cache_dir=full_model_path
|
cache_dir=full_model_path,
|
||||||
|
# Add force_download=False to use cached version if available
|
||||||
|
force_download=False,
|
||||||
|
# Add local_files_only=False to allow downloading if needed
|
||||||
|
local_files_only=False
|
||||||
)
|
)
|
||||||
self.model.eval()
|
self.model.eval()
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
self.model = self.model.cuda()
|
self.model = self.model.cuda()
|
||||||
self.model_cache[model_path] = self.model
|
self.model_cache[model_path] = self.model
|
||||||
log_info("Model loaded successfully from Hugging Face")
|
log_info("Model loaded successfully from Hugging Face")
|
||||||
|
except AttributeError as e:
|
||||||
|
if "'Config' object has no attribute 'is_encoder_decoder'" in str(e):
|
||||||
|
log_error("Compatibility issue detected with transformers library. This has been fixed in the code.")
|
||||||
|
log_error("If you're still seeing this error, please clear the model cache and try again.")
|
||||||
|
raise RuntimeError(
|
||||||
|
"Model configuration compatibility issue detected. "
|
||||||
|
f"Please delete the model cache directory '{full_model_path}' and restart ComfyUI. "
|
||||||
|
"This will download a fresh copy of the model with the updated configuration."
|
||||||
|
) from e
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
except JSONDecodeError as e:
|
except JSONDecodeError as e:
|
||||||
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
|
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -758,6 +912,95 @@ class BiRefNetMatting:
|
|||||||
|
|
||||||
_matting_lock = None
|
_matting_lock = None
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/matting/check-model")
|
||||||
|
async def check_matting_model(request):
|
||||||
|
"""Check if the matting model is available and ready to use"""
|
||||||
|
try:
|
||||||
|
if not TRANSFORMERS_AVAILABLE:
|
||||||
|
return web.json_response({
|
||||||
|
"available": False,
|
||||||
|
"reason": "missing_dependency",
|
||||||
|
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check if model exists in cache
|
||||||
|
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models")
|
||||||
|
model_path = os.path.join(base_path, "BiRefNet")
|
||||||
|
|
||||||
|
# Look for the actual BiRefNet model structure
|
||||||
|
model_files_exist = False
|
||||||
|
if os.path.exists(model_path):
|
||||||
|
# BiRefNet model from Hugging Face has a specific structure
|
||||||
|
# Check for subdirectories that indicate the model is downloaded
|
||||||
|
existing_items = os.listdir(model_path) if os.path.isdir(model_path) else []
|
||||||
|
|
||||||
|
# Look for the model subdirectory (usually named with the model ID)
|
||||||
|
model_subdirs = [d for d in existing_items if os.path.isdir(os.path.join(model_path, d)) and
|
||||||
|
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")]
|
||||||
|
|
||||||
|
if model_subdirs:
|
||||||
|
# Found model subdirectory, check inside for actual model files
|
||||||
|
for subdir in model_subdirs:
|
||||||
|
subdir_path = os.path.join(model_path, subdir)
|
||||||
|
# Navigate through the cache structure
|
||||||
|
if os.path.exists(os.path.join(subdir_path, "snapshots")):
|
||||||
|
snapshots_path = os.path.join(subdir_path, "snapshots")
|
||||||
|
snapshot_dirs = os.listdir(snapshots_path) if os.path.isdir(snapshots_path) else []
|
||||||
|
|
||||||
|
for snapshot in snapshot_dirs:
|
||||||
|
snapshot_path = os.path.join(snapshots_path, snapshot)
|
||||||
|
snapshot_files = os.listdir(snapshot_path) if os.path.isdir(snapshot_path) else []
|
||||||
|
|
||||||
|
# Check for essential files - BiRefNet uses model.safetensors
|
||||||
|
has_config = "config.json" in snapshot_files
|
||||||
|
has_model = "model.safetensors" in snapshot_files or "pytorch_model.bin" in snapshot_files
|
||||||
|
has_backbone = "backbone_swin.pth" in snapshot_files or "swin_base_patch4_window12_384_22kto1k.pth" in snapshot_files
|
||||||
|
has_birefnet = "birefnet.pth" in snapshot_files or any(f.endswith(".pth") for f in snapshot_files)
|
||||||
|
|
||||||
|
# Model is valid if it has config and either model.safetensors or other model files
|
||||||
|
if has_config and (has_model or has_backbone or has_birefnet):
|
||||||
|
model_files_exist = True
|
||||||
|
log_info(f"Found model files in: {snapshot_path} (config: {has_config}, model: {has_model})")
|
||||||
|
break
|
||||||
|
|
||||||
|
if model_files_exist:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also check if there are .pth files directly in the model_path
|
||||||
|
if not model_files_exist:
|
||||||
|
direct_files = existing_items
|
||||||
|
has_config = "config.json" in direct_files
|
||||||
|
has_model_files = any(f.endswith((".pth", ".bin", ".safetensors")) for f in direct_files)
|
||||||
|
model_files_exist = has_config and has_model_files
|
||||||
|
|
||||||
|
if model_files_exist:
|
||||||
|
log_info(f"Found model files directly in: {model_path}")
|
||||||
|
|
||||||
|
if model_files_exist:
|
||||||
|
# Model files exist, assume it's ready
|
||||||
|
log_info("BiRefNet model files detected")
|
||||||
|
return web.json_response({
|
||||||
|
"available": True,
|
||||||
|
"reason": "ready",
|
||||||
|
"message": "Model is ready to use"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
log_info(f"BiRefNet model not found in {model_path}")
|
||||||
|
return web.json_response({
|
||||||
|
"available": False,
|
||||||
|
"reason": "not_downloaded",
|
||||||
|
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
|
||||||
|
"model_path": model_path
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error checking matting model: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
"available": False,
|
||||||
|
"reason": "error",
|
||||||
|
"message": f"Error checking model status: {str(e)}"
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
@PromptServer.instance.routes.post("/matting")
|
@PromptServer.instance.routes.post("/matting")
|
||||||
async def matting(request):
|
async def matting(request):
|
||||||
global _matting_lock
|
global _matting_lock
|
||||||
@@ -911,13 +1154,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
|
|||||||
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
log_error(f"Error in convert_tensor_to_base64: {str(e)}")
|
||||||
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
CanvasNode.setup_routes()
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
|
||||||
"CanvasNode": CanvasNode
|
|
||||||
}
|
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
||||||
"CanvasNode": "LayerForge"
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"last_node_id": 49,
|
"last_node_id": 52,
|
||||||
"last_link_id": 112,
|
"last_link_id": 114,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": 7,
|
"id": 7,
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"flags": {
|
"flags": {
|
||||||
"collapsed": true
|
"collapsed": true
|
||||||
},
|
},
|
||||||
"order": 6,
|
"order": 8,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
58
|
58
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 8,
|
"order": 10,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
26
|
26
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 7,
|
"order": 9,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
46
|
46
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 12,
|
"order": 14,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -304,7 +304,7 @@
|
|||||||
58
|
58
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 10,
|
"order": 12,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -344,7 +344,7 @@
|
|||||||
138
|
138
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 9,
|
"order": 11,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -365,12 +365,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pixels",
|
"name": "pixels",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 106
|
"link": 113
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 107
|
"link": 114
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
262
|
262
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 11,
|
"order": 13,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": [
|
||||||
858769863184862,
|
1006953529460557,
|
||||||
"randomize",
|
"randomize",
|
||||||
20,
|
20,
|
||||||
1,
|
1,
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
893.8499755859375
|
893.8499755859375
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 13,
|
"order": 15,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -550,15 +550,15 @@
|
|||||||
"id": 23,
|
"id": 23,
|
||||||
"type": "CLIPTextEncode",
|
"type": "CLIPTextEncode",
|
||||||
"pos": [
|
"pos": [
|
||||||
-835.4583129882812,
|
-905.195556640625,
|
||||||
878.8148193359375
|
924.5140991210938
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
311.0955810546875,
|
311.0955810546875,
|
||||||
108.43277740478516
|
108.43277740478516
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 5,
|
"order": 7,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
@@ -591,48 +591,94 @@
|
|||||||
"bgcolor": "#353"
|
"bgcolor": "#353"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 48,
|
"id": 51,
|
||||||
"type": "CanvasNode",
|
"type": "Note",
|
||||||
"pos": [
|
"pos": [
|
||||||
-514.2837524414062,
|
-916.8970947265625,
|
||||||
543.1272583007812
|
476.72564697265625
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
1862.893798828125,
|
350.92510986328125,
|
||||||
1237.79638671875
|
250.50831604003906
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 4,
|
"order": 4,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [],
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"type": "Note",
|
||||||
|
"pos": [
|
||||||
|
-911.10205078125,
|
||||||
|
769.1378173828125
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
350.28143310546875,
|
||||||
|
99.23915100097656
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"Add a description at the bottom to tell the model what to generate."
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"type": "LayerForgeNode",
|
||||||
|
"pos": [
|
||||||
|
-553.9073486328125,
|
||||||
|
478.2644348144531
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
1879.927490234375,
|
||||||
|
1259.4072265625
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
{
|
||||||
"name": "image",
|
"name": "image",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"links": [
|
"links": [
|
||||||
106
|
113
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"links": [
|
"links": [
|
||||||
107
|
114
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "Comfyui-Ycanvas",
|
"cnr_id": "layerforge",
|
||||||
"ver": "3941104bd59dd79c19d612da1b11c05d87c2ed1c",
|
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||||
"Node name for S&R": "CanvasNode",
|
"Node name for S&R": "LayerForgeNode",
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": [
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
963,
|
18,
|
||||||
"48",
|
"50",
|
||||||
""
|
""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -734,22 +780,6 @@
|
|||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
106,
|
|
||||||
48,
|
|
||||||
0,
|
|
||||||
38,
|
|
||||||
3,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
107,
|
|
||||||
48,
|
|
||||||
1,
|
|
||||||
38,
|
|
||||||
4,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
110,
|
110,
|
||||||
38,
|
38,
|
||||||
@@ -773,6 +803,22 @@
|
|||||||
8,
|
8,
|
||||||
0,
|
0,
|
||||||
"LATENT"
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
113,
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
38,
|
||||||
|
3,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
114,
|
||||||
|
50,
|
||||||
|
1,
|
||||||
|
38,
|
||||||
|
4,
|
||||||
|
"MASK"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"groups": [],
|
"groups": [],
|
||||||
@@ -781,8 +827,8 @@
|
|||||||
"ds": {
|
"ds": {
|
||||||
"scale": 0.6588450000000008,
|
"scale": 0.6588450000000008,
|
||||||
"offset": [
|
"offset": [
|
||||||
1318.77716124466,
|
1117.7398801488407,
|
||||||
-32.39290875553955
|
-110.40634975151642
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ue_links": [],
|
"ue_links": [],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,19 +1,137 @@
|
|||||||
{
|
{
|
||||||
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"last_node_id": 707,
|
"last_node_id": 710,
|
||||||
"last_link_id": 1499,
|
"last_link_id": 1505,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 708,
|
||||||
|
"type": "LayerForgeNode",
|
||||||
|
"pos": [
|
||||||
|
-3077.55615234375,
|
||||||
|
-3358.0537109375
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
1150,
|
||||||
|
1000
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
1500
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mask",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": [
|
||||||
|
1501
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "layerforge",
|
||||||
|
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||||
|
"widget_ue_connectable": {},
|
||||||
|
"Node name for S&R": "LayerForgeNode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
11,
|
||||||
|
"708",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 709,
|
||||||
|
"type": "Reroute",
|
||||||
|
"pos": [
|
||||||
|
-1920.4510498046875,
|
||||||
|
-3559.688232421875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
75,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "*",
|
||||||
|
"link": 1500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
1502,
|
||||||
|
1503
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"showOutputText": false,
|
||||||
|
"horizontal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 710,
|
||||||
|
"type": "Reroute",
|
||||||
|
"pos": [
|
||||||
|
-1917.6273193359375,
|
||||||
|
-3524.312744140625
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
75,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "*",
|
||||||
|
"link": 1501
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": [
|
||||||
|
1504,
|
||||||
|
1505
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"showOutputText": false,
|
||||||
|
"horizontal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 369,
|
"id": 369,
|
||||||
"type": "PreviewImage",
|
"type": "PreviewImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1699.1021728515625,
|
-1914.3177490234375,
|
||||||
-3355.60498046875
|
-2807.92724609375
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
660.91162109375,
|
710,
|
||||||
400.2092590332031
|
450
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 6,
|
"order": 6,
|
||||||
@@ -38,21 +156,21 @@
|
|||||||
"id": 606,
|
"id": 606,
|
||||||
"type": "PreviewImage",
|
"type": "PreviewImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1911.126708984375,
|
-1913.4202880859375,
|
||||||
-2916.072998046875
|
-3428.773193359375
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
551.7399291992188,
|
700,
|
||||||
546.8018798828125
|
510
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 1,
|
"order": 3,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "images",
|
"name": "images",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 1495
|
"link": 1503
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [],
|
"outputs": [],
|
||||||
@@ -64,92 +182,30 @@
|
|||||||
},
|
},
|
||||||
"widgets_values": []
|
"widgets_values": []
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": 603,
|
|
||||||
"type": "PreviewImage",
|
|
||||||
"pos": [
|
|
||||||
-1344.1650390625,
|
|
||||||
-2915.117919921875
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
601.4136962890625,
|
|
||||||
527.1531372070312
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 4,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"name": "images",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"link": 1236
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputs": [],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "comfy-core",
|
|
||||||
"ver": "0.3.34",
|
|
||||||
"Node name for S&R": "PreviewImage",
|
|
||||||
"widget_ue_connectable": {}
|
|
||||||
},
|
|
||||||
"widgets_values": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 680,
|
|
||||||
"type": "SaveImage",
|
|
||||||
"pos": [
|
|
||||||
-1025.9984130859375,
|
|
||||||
-3357.975341796875
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
278.8309020996094,
|
|
||||||
395.84002685546875
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 5,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [
|
|
||||||
{
|
|
||||||
"name": "images",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"link": 1465
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputs": [],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "comfy-core",
|
|
||||||
"ver": "0.3.34",
|
|
||||||
"Node name for S&R": "SaveImage",
|
|
||||||
"widget_ue_connectable": {}
|
|
||||||
},
|
|
||||||
"widgets_values": [
|
|
||||||
"ComfyUI"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": 442,
|
"id": 442,
|
||||||
"type": "JoinImageWithAlpha",
|
"type": "JoinImageWithAlpha",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1902.5858154296875,
|
-1190.1787109375,
|
||||||
-3187.159423828125
|
-3237.75732421875
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
176.86483764648438,
|
176.86483764648438,
|
||||||
46
|
46
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 2,
|
"order": 5,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "image",
|
"name": "image",
|
||||||
"type": "IMAGE",
|
"type": "IMAGE",
|
||||||
"link": 1494
|
"link": 1502
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "alpha",
|
"name": "alpha",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 1497
|
"link": 1505
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -170,25 +226,87 @@
|
|||||||
},
|
},
|
||||||
"widgets_values": []
|
"widgets_values": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 603,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [
|
||||||
|
-1188.5968017578125,
|
||||||
|
-3143.6875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
640,
|
||||||
|
510
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 7,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1236
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "PreviewImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 680,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [
|
||||||
|
-536.2315673828125,
|
||||||
|
-3135.49755859375
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
279.97137451171875,
|
||||||
|
282
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 8,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1465
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "SaveImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"ComfyUI"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": 706,
|
"id": 706,
|
||||||
"type": "MaskToImage",
|
"type": "MaskToImage",
|
||||||
"pos": [
|
"pos": [
|
||||||
-1901.433349609375,
|
-1911.38525390625,
|
||||||
-3332.2021484375
|
-2875.74658203125
|
||||||
],
|
],
|
||||||
"size": [
|
"size": [
|
||||||
184.62362670898438,
|
184.62362670898438,
|
||||||
26
|
26
|
||||||
],
|
],
|
||||||
"flags": {},
|
"flags": {},
|
||||||
"order": 3,
|
"order": 4,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "mask",
|
"name": "mask",
|
||||||
"type": "MASK",
|
"type": "MASK",
|
||||||
"link": 1498
|
"link": 1504
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -203,57 +321,10 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "comfy-core",
|
"cnr_id": "comfy-core",
|
||||||
"ver": "0.3.44",
|
"ver": "0.3.44",
|
||||||
"widget_ue_connectable": {},
|
"Node name for S&R": "MaskToImage",
|
||||||
"Node name for S&R": "MaskToImage"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 697,
|
|
||||||
"type": "CanvasNode",
|
|
||||||
"pos": [
|
|
||||||
-2968.572998046875,
|
|
||||||
-3347.89306640625
|
|
||||||
],
|
|
||||||
"size": [
|
|
||||||
1044.9053955078125,
|
|
||||||
980.680908203125
|
|
||||||
],
|
|
||||||
"flags": {},
|
|
||||||
"order": 0,
|
|
||||||
"mode": 0,
|
|
||||||
"inputs": [],
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "image",
|
|
||||||
"type": "IMAGE",
|
|
||||||
"links": [
|
|
||||||
1494,
|
|
||||||
1495
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mask",
|
|
||||||
"type": "MASK",
|
|
||||||
"links": [
|
|
||||||
1497,
|
|
||||||
1498
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"cnr_id": "Comfyui-Ycanvas",
|
|
||||||
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
|
|
||||||
"Node name for S&R": "CanvasNode",
|
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
"widgets_values": [
|
"widgets_values": []
|
||||||
true,
|
|
||||||
false,
|
|
||||||
"697",
|
|
||||||
15,
|
|
||||||
"697",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -273,38 +344,6 @@
|
|||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
1494,
|
|
||||||
697,
|
|
||||||
0,
|
|
||||||
442,
|
|
||||||
0,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1495,
|
|
||||||
697,
|
|
||||||
0,
|
|
||||||
606,
|
|
||||||
0,
|
|
||||||
"IMAGE"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1497,
|
|
||||||
697,
|
|
||||||
1,
|
|
||||||
442,
|
|
||||||
1,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
1498,
|
|
||||||
697,
|
|
||||||
1,
|
|
||||||
706,
|
|
||||||
0,
|
|
||||||
"MASK"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
1499,
|
1499,
|
||||||
706,
|
706,
|
||||||
@@ -312,16 +351,64 @@
|
|||||||
369,
|
369,
|
||||||
0,
|
0,
|
||||||
"IMAGE"
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1500,
|
||||||
|
708,
|
||||||
|
0,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1501,
|
||||||
|
708,
|
||||||
|
1,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1502,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
442,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1503,
|
||||||
|
709,
|
||||||
|
0,
|
||||||
|
606,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1504,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
706,
|
||||||
|
0,
|
||||||
|
"MASK"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1505,
|
||||||
|
710,
|
||||||
|
0,
|
||||||
|
442,
|
||||||
|
1,
|
||||||
|
"MASK"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"config": {},
|
"config": {},
|
||||||
"extra": {
|
"extra": {
|
||||||
"ds": {
|
"ds": {
|
||||||
"scale": 0.9646149645000008,
|
"scale": 0.7972024500000005,
|
||||||
"offset": [
|
"offset": [
|
||||||
3002.5649125522764,
|
3208.3419155969927,
|
||||||
3543.443319064718
|
3617.011371212156
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ue_links": [],
|
"ue_links": [],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 854 KiB |
@@ -123,10 +123,14 @@ export class BatchPreviewManager {
|
|||||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||||
if (this.maskWasVisible) {
|
if (this.maskWasVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.remove('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.remove('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '0.5';
|
iconContainer.style.opacity = '0.5';
|
||||||
}
|
}
|
||||||
@@ -165,10 +169,14 @@ export class BatchPreviewManager {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.add('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.add('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '1';
|
iconContainer.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|||||||
22
js/Canvas.js
22
js/Canvas.js
@@ -61,9 +61,20 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
this.offscreenCtx = offscreenCtx;
|
||||||
|
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||||
|
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||||
|
alpha: true,
|
||||||
|
willReadFrequently: false
|
||||||
|
});
|
||||||
|
if (!overlayCtx)
|
||||||
|
throw new Error("Could not create overlay canvas context");
|
||||||
|
this.overlayCanvas = overlayCanvas;
|
||||||
|
this.overlayCtx = overlayCtx;
|
||||||
this.canvasContainer = null;
|
this.canvasContainer = null;
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
this.requestSaveState = () => { };
|
this.requestSaveState = () => { };
|
||||||
this.outputAreaShape = null;
|
this.outputAreaShape = null;
|
||||||
@@ -363,6 +374,10 @@ export class Canvas {
|
|||||||
return widget ? widget.value : false;
|
return widget ? widget.value : false;
|
||||||
};
|
};
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -385,6 +400,9 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
if (!this.pendingBatchContext) {
|
if (!this.pendingBatchContext) {
|
||||||
@@ -425,8 +443,8 @@ export class Canvas {
|
|||||||
* Inicjalizuje podstawowe właściwości canvas
|
* Inicjalizuje podstawowe właściwości canvas
|
||||||
*/
|
*/
|
||||||
initCanvas() {
|
initCanvas() {
|
||||||
this.canvas.width = this.width;
|
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||||
this.canvas.height = this.height;
|
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||||
this.canvas.style.border = '1px solid black';
|
this.canvas.style.border = '1px solid black';
|
||||||
this.canvas.style.maxWidth = '100%';
|
this.canvas.style.maxWidth = '100%';
|
||||||
this.canvas.style.backgroundColor = '#606060';
|
this.canvas.style.backgroundColor = '#606060';
|
||||||
|
|||||||
561
js/CanvasIO.js
561
js/CanvasIO.js
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
const log = createModuleLogger('CanvasIO');
|
const log = createModuleLogger('CanvasIO');
|
||||||
export class CanvasIO {
|
export class CanvasIO {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -196,6 +197,25 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
async _renderOutputData() {
|
async _renderOutputData() {
|
||||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||||
|
// Check if layers have valid images loaded, with retry logic
|
||||||
|
const maxRetries = 5;
|
||||||
|
const retryDelay = 200;
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
|
||||||
|
if (layersWithoutImages.length === 0) {
|
||||||
|
break; // All images loaded
|
||||||
|
}
|
||||||
|
if (attempt === 0) {
|
||||||
|
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
|
||||||
|
}
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Last attempt failed
|
||||||
|
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||||
@@ -238,23 +258,21 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||||
|
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||||
|
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||||
|
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async addInputToCanvas(inputImage, inputMask) {
|
async addInputToCanvas(inputImage, inputMask) {
|
||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx)
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
throw new Error("Could not create temp context");
|
if (!imageData)
|
||||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
|
throw new Error("Failed to convert input image tensor");
|
||||||
tempCtx.putImageData(imgData, 0, 0);
|
// Create HTMLImageElement from ImageData
|
||||||
const image = new Image();
|
const image = await createImageFromImageData(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
||||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||||
@@ -280,17 +298,10 @@ export class CanvasIO {
|
|||||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx)
|
if (!imageData)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Failed to convert tensor to image data");
|
||||||
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
|
return await createImageFromImageData(imageData);
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
@@ -311,12 +322,26 @@ export class CanvasIO {
|
|||||||
async initNodeData() {
|
async initNodeData() {
|
||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
log.info("Starting node data initialization...");
|
||||||
|
// First check for input data from the backend (new feature)
|
||||||
|
await this.checkForInputData();
|
||||||
|
// If we've already loaded input data, don't continue with old initialization
|
||||||
|
if (this.canvas.inputDataLoaded) {
|
||||||
|
log.debug("Input data already loaded, skipping old initialization");
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.canvas.node || !this.canvas.node.inputs) {
|
if (!this.canvas.node || !this.canvas.node.inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
const imageLinkId = this.canvas.node.inputs[0].link;
|
const imageLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
// Check if we already loaded this link
|
||||||
|
if (this.canvas.lastLoadedLinkId === imageLinkId) {
|
||||||
|
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const imageData = window.app.nodeOutputs[imageLinkId];
|
const imageData = window.app.nodeOutputs[imageLinkId];
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
log.debug("Found image data:", imageData);
|
log.debug("Found image data:", imageData);
|
||||||
@@ -328,6 +353,10 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// No input connected, mark as initialized to stop repeated checks
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
}
|
||||||
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||||
@@ -342,6 +371,390 @@ export class CanvasIO {
|
|||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async checkForInputData(options) {
|
||||||
|
try {
|
||||||
|
const nodeId = this.canvas.node.id;
|
||||||
|
const allowImage = options?.allowImage ?? true;
|
||||||
|
const allowMask = options?.allowMask ?? true;
|
||||||
|
const reason = options?.reason ?? 'unspecified';
|
||||||
|
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
|
||||||
|
// Track loaded links separately for image and mask
|
||||||
|
let imageLoaded = false;
|
||||||
|
let maskLoaded = false;
|
||||||
|
let imageChanged = false;
|
||||||
|
// First, try to get data from connected node's output if available (IMAGES)
|
||||||
|
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const linkId = this.canvas.node.inputs[0].link;
|
||||||
|
const graph = this.canvas.node.graph;
|
||||||
|
// Always check if images have changed first
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[linkId];
|
||||||
|
if (link) {
|
||||||
|
const sourceNode = graph.getNodeById(link.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// Create current batch identifier (all image sources combined)
|
||||||
|
const currentBatchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
|
||||||
|
// Check if this is the same link we loaded before
|
||||||
|
if (this.canvas.lastLoadedLinkId === linkId) {
|
||||||
|
// Same link, check if images actually changed
|
||||||
|
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
|
||||||
|
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
|
||||||
|
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
|
||||||
|
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
|
||||||
|
imageChanged = true;
|
||||||
|
// Clear the inputDataLoaded flag to force reload from backend
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
// Clear the lastLoadedImageSrc to force reload
|
||||||
|
this.canvas.lastLoadedImageSrc = undefined;
|
||||||
|
// Clear backend data to force fresh load
|
||||||
|
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
|
||||||
|
.then(() => log.debug("Backend input data cleared due to image change"))
|
||||||
|
.catch(err => log.error("Failed to clear backend data:", err));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
|
||||||
|
imageLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Different link or first load
|
||||||
|
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
|
||||||
|
imageChanged = false; // It's not a change, it's a new link
|
||||||
|
imageLoaded = false; // Need to load
|
||||||
|
// Reset the inputDataLoaded flag for new link
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!imageLoaded || imageChanged) {
|
||||||
|
// Reset the inputDataLoaded flag when images change
|
||||||
|
if (imageChanged) {
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
log.info("Resetting inputDataLoaded flag due to image change");
|
||||||
|
}
|
||||||
|
if (this.canvas.node.graph) {
|
||||||
|
const graph2 = this.canvas.node.graph;
|
||||||
|
const link2 = graph2.links[linkId];
|
||||||
|
if (link2) {
|
||||||
|
const sourceNode = graph2.getNodeById(link2.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// The connected node has images in its output - handle multiple images (batch)
|
||||||
|
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
|
||||||
|
// Create a combined source identifier for batch detection
|
||||||
|
const batchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
|
||||||
|
// Mark this link and batch sources as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = linkId;
|
||||||
|
this.canvas.lastLoadedImageSrc = batchImageSrcs;
|
||||||
|
// Don't clear layers - just add new ones
|
||||||
|
if (imageChanged) {
|
||||||
|
log.info("Image change detected, will add new layers");
|
||||||
|
}
|
||||||
|
// Determine add mode
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
// Add all images from the batch as separate layers
|
||||||
|
for (let i = 0; i < sourceNode.imgs.length; i++) {
|
||||||
|
const img = sourceNode.imgs[i];
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, // Give each layer a unique name
|
||||||
|
addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
|
||||||
|
}
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
imageLoaded = true;
|
||||||
|
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for mask input separately (from nodeOutputs) ONLY when allowed
|
||||||
|
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Check if we already loaded this mask link
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
|
||||||
|
log.debug(`Mask link ${maskLinkId} already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
|
||||||
|
const graph = this.canvas.node.graph;
|
||||||
|
let maskOutput = null;
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[maskLinkId];
|
||||||
|
if (link && link.origin_id) {
|
||||||
|
// Use origin_id to get the actual node output
|
||||||
|
const nodeOutput = window.app?.nodeOutputs?.[link.origin_id];
|
||||||
|
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
|
||||||
|
if (nodeOutput) {
|
||||||
|
log.debug(`Node ${link.origin_id} output structure:`, {
|
||||||
|
hasData: !!nodeOutput.data,
|
||||||
|
hasShape: !!nodeOutput.shape,
|
||||||
|
dataType: typeof nodeOutput.data,
|
||||||
|
shapeType: typeof nodeOutput.shape,
|
||||||
|
keys: Object.keys(nodeOutput)
|
||||||
|
});
|
||||||
|
// Only use if it has actual tensor data
|
||||||
|
if (nodeOutput.data && nodeOutput.shape) {
|
||||||
|
maskOutput = nodeOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maskOutput && maskOutput.data && maskOutput.shape) {
|
||||||
|
try {
|
||||||
|
// Derive dimensions from shape or explicit width/height
|
||||||
|
let width = maskOutput.width || 0;
|
||||||
|
let height = maskOutput.height || 0;
|
||||||
|
const shape = maskOutput.shape; // e.g. [1,H,W] or [1,H,W,1]
|
||||||
|
if ((!width || !height) && Array.isArray(shape)) {
|
||||||
|
if (shape.length >= 3) {
|
||||||
|
height = shape[1];
|
||||||
|
width = shape[2];
|
||||||
|
}
|
||||||
|
else if (shape.length === 2) {
|
||||||
|
height = shape[0];
|
||||||
|
width = shape[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error("Cannot determine mask dimensions from nodeOutputs");
|
||||||
|
}
|
||||||
|
// Determine channels count
|
||||||
|
let channels = 1;
|
||||||
|
if (Array.isArray(shape) && shape.length >= 4) {
|
||||||
|
channels = shape[3];
|
||||||
|
}
|
||||||
|
else if (maskOutput.channels) {
|
||||||
|
channels = maskOutput.channels;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const len = maskOutput.data.length;
|
||||||
|
channels = Math.max(1, Math.floor(len / (width * height)));
|
||||||
|
}
|
||||||
|
// Use unified tensorToImageData for masks
|
||||||
|
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
|
||||||
|
if (!maskImageData)
|
||||||
|
throw new Error("Failed to convert mask tensor to image data");
|
||||||
|
// Create canvas and put image data
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create mask context");
|
||||||
|
ctx.putImageData(maskImageData, 0, 0);
|
||||||
|
// Convert to HTMLImageElement
|
||||||
|
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
|
||||||
|
// Respect fit_on_add (scale to output area)
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
|
||||||
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
|
||||||
|
let finalMaskImg = maskImg;
|
||||||
|
if (shouldFit) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
this.canvas.maskAppliedFromInput = true;
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
this.canvas.render();
|
||||||
|
// Mark this mask link as loaded to avoid re-applying
|
||||||
|
this.canvas.lastLoadedMaskLinkId = maskLinkId;
|
||||||
|
maskLoaded = true;
|
||||||
|
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
|
||||||
|
log.info(`Mask node ${this.canvas.node.graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
|
||||||
|
// Don't retry - data won't be available until workflow runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only check backend if we have actual inputs connected
|
||||||
|
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
|
||||||
|
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
|
||||||
|
// If mask input is disconnected, clear any currently applied mask to ensure full separation
|
||||||
|
if (!hasMaskInput) {
|
||||||
|
this.canvas.maskAppliedFromInput = false;
|
||||||
|
this.canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
|
||||||
|
}
|
||||||
|
if (!hasImageInput && !hasMaskInput) {
|
||||||
|
log.debug("No inputs connected, skipping backend check");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip backend check during mask connection if we didn't get immediate data
|
||||||
|
if (reason === "mask_connect" && !maskLoaded) {
|
||||||
|
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check backend for input data only if we have connected inputs
|
||||||
|
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.has_input) {
|
||||||
|
// Dedupe: skip only if backend payload matches last loaded batch hash
|
||||||
|
let backendBatchHash;
|
||||||
|
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
|
||||||
|
backendBatchHash = result.data.input_images_batch.map((i) => i.data).join('|');
|
||||||
|
}
|
||||||
|
else if (result.data?.input_image) {
|
||||||
|
backendBatchHash = result.data.input_image;
|
||||||
|
}
|
||||||
|
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
|
||||||
|
const shouldCheckMask = hasMaskInput && allowMask;
|
||||||
|
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
|
||||||
|
log.debug("Backend input data unchanged and no mask to check, skipping reload");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
|
||||||
|
log.debug("Images unchanged but need to check mask, continuing...");
|
||||||
|
imageLoaded = true; // Mark images as already loaded to skip reloading them
|
||||||
|
}
|
||||||
|
// Check if we already loaded image data (by checking the current link)
|
||||||
|
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const currentLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
|
||||||
|
// Mark this link as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = currentLinkId;
|
||||||
|
imageLoaded = false; // Will load from backend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
|
||||||
|
// Only reset if the mask link actually changed
|
||||||
|
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
|
||||||
|
const currentMaskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Only reset if this is a different mask link than what we loaded before
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
|
||||||
|
maskLoaded = false;
|
||||||
|
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No mask input connected, or mask loading not allowed right now
|
||||||
|
maskLoaded = true; // Mark as loaded to skip mask processing
|
||||||
|
if (!allowMask) {
|
||||||
|
log.debug("Mask loading is currently disabled by caller, skipping mask check");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No mask input connected, skipping mask check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Input data found from backend, adding to canvas");
|
||||||
|
const inputData = result.data;
|
||||||
|
// Compute backend batch hash for dedupe and state
|
||||||
|
let backendHashNow;
|
||||||
|
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
|
||||||
|
backendHashNow = inputData.input_images_batch.map((i) => i.data).join('|');
|
||||||
|
}
|
||||||
|
else if (inputData?.input_image) {
|
||||||
|
backendHashNow = inputData.input_image;
|
||||||
|
}
|
||||||
|
// Just update the hash without removing any layers
|
||||||
|
if (backendHashNow) {
|
||||||
|
log.info("New backend input data detected, adding new layers");
|
||||||
|
this.canvas.lastLoadedImageSrc = backendHashNow;
|
||||||
|
}
|
||||||
|
// Mark that we've loaded input data for this execution
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
// Determine add mode based on fit_on_add setting
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
|
||||||
|
if (allowImage && !imageLoaded && hasImageInput) {
|
||||||
|
if (inputData.input_images_batch) {
|
||||||
|
// Handle batch of images
|
||||||
|
const batch = inputData.input_images_batch;
|
||||||
|
log.info(`Processing batch of ${batch.length} images from backend`);
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
const imgData = batch[i];
|
||||||
|
const img = await createImageFromSource(imgData.data);
|
||||||
|
// Add image to canvas with unique name
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
|
||||||
|
}
|
||||||
|
log.info(`All ${batch.length} batch images added from backend`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
else if (inputData.input_image) {
|
||||||
|
// Handle single image (backward compatibility)
|
||||||
|
const img = await createImageFromSource(inputData.input_image);
|
||||||
|
// Add image to canvas at output area position
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode, this.canvas.outputAreaBounds);
|
||||||
|
log.info("Single input image added as new layer to canvas");
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No input image data from backend");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
|
||||||
|
log.debug("Backend has image data but no image input connected, skipping image load");
|
||||||
|
}
|
||||||
|
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
|
||||||
|
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
|
||||||
|
log.info("Processing input mask");
|
||||||
|
// Load mask image
|
||||||
|
const maskImg = await createImageFromSource(inputData.input_mask);
|
||||||
|
// Determine if we should fit the mask or use it at original size
|
||||||
|
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
|
||||||
|
let finalMaskImg = maskImg;
|
||||||
|
if (shouldFit && this.canvas.maskTool) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
}
|
||||||
|
this.canvas.maskAppliedFromInput = true;
|
||||||
|
// Save the mask state
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
|
||||||
|
}
|
||||||
|
else if (!hasMaskInput && inputData.input_mask) {
|
||||||
|
log.debug("Backend has mask data but no mask input connected, skipping mask load");
|
||||||
|
}
|
||||||
|
else if (!allowMask && inputData.input_mask) {
|
||||||
|
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("No input data from backend");
|
||||||
|
// Don't schedule another check - we'll only check when explicitly triggered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error checking for input data:", error);
|
||||||
|
// Don't schedule another check on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleInputDataCheck() {
|
||||||
|
// Schedule a retry for mask data check when nodeOutputs are not ready yet
|
||||||
|
if (this.canvas.pendingInputDataCheck) {
|
||||||
|
clearTimeout(this.canvas.pendingInputDataCheck);
|
||||||
|
}
|
||||||
|
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
|
||||||
|
this.canvas.pendingInputDataCheck = null;
|
||||||
|
log.debug("Retrying input data check for mask...");
|
||||||
|
}, 500); // Shorter delay for mask data retry
|
||||||
|
}
|
||||||
scheduleDataCheck() {
|
scheduleDataCheck() {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -420,51 +833,10 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
convertTensorToImageData(tensor) {
|
convertTensorToImageData(tensor) {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
const shape = tensor.shape;
|
|
||||||
const height = shape[1];
|
|
||||||
const width = shape[2];
|
|
||||||
const channels = shape[3];
|
|
||||||
log.debug("Converting tensor:", {
|
|
||||||
shape: shape,
|
|
||||||
dataRange: {
|
|
||||||
min: tensor.min_val,
|
|
||||||
max: tensor.max_val
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const imageData = new ImageData(width, height);
|
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
|
||||||
const flatData = tensor.data;
|
|
||||||
const pixelCount = width * height;
|
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
|
||||||
const pixelIndex = i * 4;
|
|
||||||
const tensorIndex = i * channels;
|
|
||||||
for (let c = 0; c < channels; c++) {
|
|
||||||
const value = flatData[tensorIndex + c];
|
|
||||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
|
||||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
|
||||||
}
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
imageData.data.set(data);
|
|
||||||
return imageData;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
log.error("Error converting tensor:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
async createImageFromData(imageData) {
|
async createImageFromData(imageData) {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
|
||||||
if (!ctx)
|
|
||||||
throw new Error("Could not create canvas context");
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
async processMaskData(maskData) {
|
async processMaskData(maskData) {
|
||||||
try {
|
try {
|
||||||
@@ -524,12 +896,7 @@ export class CanvasIO {
|
|||||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||||
const newLayers = [];
|
const newLayers = [];
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
// If there's a custom shape, clip the image to that shape
|
// If there's a custom shape, clip the image to that shape
|
||||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||||
@@ -556,33 +923,27 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async clipImageToShape(image, shape) {
|
async clipImageToShape(image, shape) {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
// Draw the image first
|
||||||
}
|
ctx.drawImage(image, 0, 0);
|
||||||
// Draw the image first
|
// Calculate custom shape position accounting for extensions
|
||||||
ctx.drawImage(image, 0, 0);
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
// Calculate custom shape position accounting for extensions
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
// Create a clipping mask using the shape with extension offset
|
||||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
// Create a clipping mask using the shape with extension offset
|
ctx.beginPath();
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
ctx.beginPath();
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
}
|
||||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
ctx.closePath();
|
||||||
}
|
ctx.fill();
|
||||||
ctx.closePath();
|
// Create a new image from the clipped canvas
|
||||||
ctx.fill();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
// Create a new image from the clipped canvas
|
|
||||||
const clippedImage = new Image();
|
|
||||||
clippedImage.onload = () => resolve(clippedImage);
|
|
||||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
|
||||||
clippedImage.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('CanvasInteractions');
|
const log = createModuleLogger('CanvasInteractions');
|
||||||
export class CanvasInteractions {
|
export class CanvasInteractions {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
|
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||||
|
this.onMouseDown = (e) => this.handleMouseDown(e);
|
||||||
|
this.onMouseMove = (e) => this.handleMouseMove(e);
|
||||||
|
this.onMouseUp = (e) => this.handleMouseUp(e);
|
||||||
|
this.onMouseEnter = (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
|
||||||
|
this.onMouseLeave = (e) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
|
||||||
|
this.onWheel = (e) => this.handleWheel(e);
|
||||||
|
this.onKeyDown = (e) => this.handleKeyDown(e);
|
||||||
|
this.onKeyUp = (e) => this.handleKeyUp(e);
|
||||||
|
this.onDragOver = (e) => this.handleDragOver(e);
|
||||||
|
this.onDragEnter = (e) => this.handleDragEnter(e);
|
||||||
|
this.onDragLeave = (e) => this.handleDragLeave(e);
|
||||||
|
this.onDrop = (e) => { this.handleDrop(e); };
|
||||||
|
this.onContextMenu = (e) => this.handleContextMenu(e);
|
||||||
|
this.onBlur = () => this.handleBlur();
|
||||||
|
this.onPaste = (e) => this.handlePasteEvent(e);
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.interaction = {
|
this.interaction = {
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
panStart: { x: 0, y: 0 },
|
panStart: { x: 0, y: 0 },
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
transformOrigin: {},
|
transformOrigin: null,
|
||||||
resizeHandle: null,
|
resizeHandle: null,
|
||||||
resizeAnchor: { x: 0, y: 0 },
|
resizeAnchor: { x: 0, y: 0 },
|
||||||
canvasResizeStart: { x: 0, y: 0 },
|
canvasResizeStart: { x: 0, y: 0 },
|
||||||
isCtrlPressed: false,
|
isCtrlPressed: false,
|
||||||
|
isMetaPressed: false,
|
||||||
isAltPressed: false,
|
isAltPressed: false,
|
||||||
isShiftPressed: false,
|
isShiftPressed: false,
|
||||||
isSPressed: false,
|
isSPressed: false,
|
||||||
@@ -22,6 +39,9 @@ export class CanvasInteractions {
|
|||||||
keyMovementInProgress: false,
|
keyMovementInProgress: false,
|
||||||
canvasResizeRect: null,
|
canvasResizeRect: null,
|
||||||
canvasMoveRect: null,
|
canvasMoveRect: null,
|
||||||
|
outputAreaTransformHandle: null,
|
||||||
|
outputAreaTransformAnchor: { x: 0, y: 0 },
|
||||||
|
hoveringGrabIcon: false,
|
||||||
};
|
};
|
||||||
this.originalLayerPositions = new Map();
|
this.originalLayerPositions = new Map();
|
||||||
}
|
}
|
||||||
@@ -32,18 +52,29 @@ export class CanvasInteractions {
|
|||||||
view: this.canvas.getMouseViewCoordinates(e)
|
view: this.canvas.getMouseViewCoordinates(e)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
getModifierState(e) {
|
||||||
|
return {
|
||||||
|
ctrl: this.interaction.isCtrlPressed || e?.ctrlKey || false,
|
||||||
|
shift: this.interaction.isShiftPressed || e?.shiftKey || false,
|
||||||
|
alt: this.interaction.isAltPressed || e?.altKey || false,
|
||||||
|
meta: this.interaction.isMetaPressed || e?.metaKey || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
preventEventDefaults(e) {
|
preventEventDefaults(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
performZoomOperation(worldCoords, zoomFactor) {
|
performZoomOperation(worldCoords, zoomFactor) {
|
||||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
|
||||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||||
this.canvas.viewport.zoom = newZoom;
|
this.canvas.viewport.zoom = newZoom;
|
||||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||||
|
// Update stroke overlay if mask tool is drawing during zoom
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
renderAndSave(shouldSave = false) {
|
renderAndSave(shouldSave = false) {
|
||||||
@@ -64,29 +95,43 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
|
||||||
// Add a blur event listener to the window to reset key states
|
// Add a blur event listener to the window to reset key states
|
||||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
window.addEventListener('blur', this.onBlur);
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.onPaste);
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
|
||||||
this.canvas.isMouseOver = true;
|
document.addEventListener('keydown', this.onKeyDown, { capture: true });
|
||||||
this.handleMouseEnter(e);
|
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||||
});
|
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', (e) => {
|
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||||
this.canvas.isMouseOver = false;
|
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
|
||||||
this.handleMouseLeave(e);
|
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
|
||||||
});
|
this.canvas.canvas.addEventListener('drop', this.onDrop);
|
||||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
}
|
||||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
teardownEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||||
|
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||||
|
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
|
||||||
|
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
|
||||||
|
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
|
||||||
|
// Remove document-level capture listener
|
||||||
|
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
|
||||||
|
window.removeEventListener('blur', this.onBlur);
|
||||||
|
document.removeEventListener('paste', this.onPaste);
|
||||||
|
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
|
||||||
|
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave);
|
||||||
|
this.canvas.canvas.removeEventListener('dragover', this.onDragOver);
|
||||||
|
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter);
|
||||||
|
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave);
|
||||||
|
this.canvas.canvas.removeEventListener('drop', this.onDrop);
|
||||||
|
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||||
@@ -111,6 +156,29 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
|
||||||
|
* Zwraca layer, jeśli kliknięto w ikonę grab
|
||||||
|
*/
|
||||||
|
getGrabIconAtPosition(worldX, worldY) {
|
||||||
|
// Rozmiar ikony grab w pikselach światowych
|
||||||
|
const grabIconRadius = 20 / this.canvas.viewport.zoom;
|
||||||
|
for (const layer of this.canvas.canvasSelection.selectedLayers) {
|
||||||
|
if (!layer.visible)
|
||||||
|
continue;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
// Sprawdź czy punkt jest w obszarze ikony grab (okrąg wokół środka)
|
||||||
|
const dx = worldX - centerX;
|
||||||
|
const dy = worldY - centerY;
|
||||||
|
const distanceSquared = dx * dx + dy * dy;
|
||||||
|
const radiusSquared = grabIconRadius * grabIconRadius;
|
||||||
|
if (distanceSquared <= radiusSquared) {
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
resetInteractionState() {
|
resetInteractionState() {
|
||||||
this.interaction.mode = 'none';
|
this.interaction.mode = 'none';
|
||||||
this.interaction.resizeHandle = null;
|
this.interaction.resizeHandle = null;
|
||||||
@@ -119,13 +187,33 @@ export class CanvasInteractions {
|
|||||||
this.interaction.canvasMoveRect = null;
|
this.interaction.canvasMoveRect = null;
|
||||||
this.interaction.hasClonedInDrag = false;
|
this.interaction.hasClonedInDrag = false;
|
||||||
this.interaction.transformingLayer = null;
|
this.interaction.transformingLayer = null;
|
||||||
|
this.interaction.outputAreaTransformHandle = null;
|
||||||
this.canvas.canvas.style.cursor = 'default';
|
this.canvas.canvas.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
handleMouseDown(e) {
|
handleMouseDown(e) {
|
||||||
this.canvas.canvas.focus();
|
this.canvas.canvas.focus();
|
||||||
|
// Sync modifier states with actual event state to prevent "stuck" modifiers
|
||||||
|
// when focus moves between layers panel and canvas
|
||||||
|
this.interaction.isCtrlPressed = e.ctrlKey;
|
||||||
|
this.interaction.isMetaPressed = e.metaKey;
|
||||||
|
this.interaction.isShiftPressed = e.shiftKey;
|
||||||
|
this.interaction.isAltPressed = e.altKey;
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||||
|
// Don't render here - mask tool will handle its own drawing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.interaction.mode === 'transformingOutputArea') {
|
||||||
|
// Check if clicking on output area transform handle
|
||||||
|
const handle = this.getOutputAreaHandle(coords.world);
|
||||||
|
if (handle) {
|
||||||
|
this.startOutputAreaTransform(handle, coords.world);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If clicking outside, exit transform mode
|
||||||
|
this.interaction.mode = 'none';
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,11 +223,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (mods.shift && mods.ctrl) {
|
||||||
this.startCanvasMove(coords.world);
|
this.startCanvasMove(coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
// Clear custom shape when starting canvas resize
|
// Clear custom shape when starting canvas resize
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||||
@@ -163,7 +251,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) { // Środkowy przycisk
|
if (e.button === 1) { // Środkowy przycisk
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,13 +261,21 @@ export class CanvasInteractions {
|
|||||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
|
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Check if clicking on grab icon of a selected layer
|
||||||
|
const grabIconLayer = this.getGrabIconAtPosition(coords.world.x, coords.world.y);
|
||||||
|
if (grabIconLayer) {
|
||||||
|
// Start dragging the selected layer(s) without changing selection
|
||||||
|
this.interaction.mode = 'potential-drag';
|
||||||
|
this.interaction.dragStart = { ...coords.world };
|
||||||
|
return;
|
||||||
|
}
|
||||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
|
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
|
||||||
if (clickedLayerResult) {
|
if (clickedLayerResult) {
|
||||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
this.startPanningOrClearSelection(e);
|
this.startPanning(e, true); // clearSelection = true
|
||||||
}
|
}
|
||||||
handleMouseMove(e) {
|
handleMouseMove(e) {
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
@@ -199,7 +295,7 @@ export class CanvasInteractions {
|
|||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
case 'drawingMask':
|
case 'drawingMask':
|
||||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render during mask drawing - it's handled by mask tool internally
|
||||||
break;
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
@@ -219,8 +315,27 @@ export class CanvasInteractions {
|
|||||||
case 'movingCanvas':
|
case 'movingCanvas':
|
||||||
this.updateCanvasMove(coords.world);
|
this.updateCanvasMove(coords.world);
|
||||||
break;
|
break;
|
||||||
|
case 'transformingOutputArea':
|
||||||
|
if (this.interaction.outputAreaTransformHandle) {
|
||||||
|
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateOutputAreaTransformCursor(coords.world);
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
|
// Check if hovering over grab icon
|
||||||
|
const wasHovering = this.interaction.hoveringGrabIcon;
|
||||||
|
this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null;
|
||||||
|
// Re-render if hover state changed to show/hide grab icon
|
||||||
|
if (wasHovering !== this.interaction.hoveringGrabIcon) {
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
this.updateCursor(coords.world);
|
this.updateCursor(coords.world);
|
||||||
|
// Update brush cursor on overlay if mask tool is active
|
||||||
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||||
@@ -232,6 +347,7 @@ export class CanvasInteractions {
|
|||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||||
|
// Render only once after drawing is complete
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,10 +357,22 @@ export class CanvasInteractions {
|
|||||||
if (this.interaction.mode === 'movingCanvas') {
|
if (this.interaction.mode === 'movingCanvas') {
|
||||||
this.finalizeCanvasMove();
|
this.finalizeCanvasMove();
|
||||||
}
|
}
|
||||||
|
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
|
||||||
|
this.finalizeOutputAreaTransform();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Log layer positions when dragging ends
|
// Log layer positions when dragging ends
|
||||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
this.logDragCompletion(coords);
|
this.logDragCompletion(coords);
|
||||||
}
|
}
|
||||||
|
// Handle end of crop bounds transformation before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
|
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||||
|
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||||
|
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||||
|
}
|
||||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||||
@@ -307,8 +435,17 @@ export class CanvasInteractions {
|
|||||||
this.performZoomOperation(coords.world, zoomFactor);
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Layer transformation when layers are selected
|
// Check if mouse is over any selected layer
|
||||||
this.handleLayerWheelTransformation(e);
|
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
|
||||||
|
if (isOverSelectedLayer) {
|
||||||
|
// Layer transformation when layers are selected and mouse is over selected layer
|
||||||
|
this.handleLayerWheelTransformation(e);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Zoom operation when mouse is not over selected layers
|
||||||
|
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
if (!this.canvas.maskTool.isActive) {
|
if (!this.canvas.maskTool.isActive) {
|
||||||
@@ -316,14 +453,15 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleLayerWheelTransformation(e) {
|
handleLayerWheelTransformation(e) {
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -363,10 +501,12 @@ export class CanvasInteractions {
|
|||||||
layer.height *= scaleFactor;
|
layer.height *= scaleFactor;
|
||||||
layer.x += (oldWidth - layer.width) / 2;
|
layer.x += (oldWidth - layer.width) / 2;
|
||||||
layer.y += (oldHeight - layer.height) / 2;
|
layer.y += (oldHeight - layer.height) / 2;
|
||||||
|
// Handle wheel scaling end for layers with blend area
|
||||||
|
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||||
const gridSize = 64;
|
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||||
const direction = deltaY > 0 ? -1 : 1;
|
const direction = deltaY > 0 ? -1 : 1;
|
||||||
let targetHeight;
|
let targetHeight;
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
@@ -389,12 +529,24 @@ export class CanvasInteractions {
|
|||||||
return targetHeight / oldHeight;
|
return targetHeight / oldHeight;
|
||||||
}
|
}
|
||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
|
// Always track modifier keys regardless of focus
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = true;
|
this.interaction.isCtrlPressed = true;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = true;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = true;
|
this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt')
|
||||||
this.interaction.isAltPressed = true;
|
this.interaction.isAltPressed = true;
|
||||||
|
// Check if canvas is focused before handling any shortcuts
|
||||||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
|
document.activeElement === this.canvas.canvas;
|
||||||
|
if (!shouldHandle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Canvas-specific key handlers (only when focused)
|
||||||
|
if (e.key === 'Alt') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 's') {
|
if (e.key.toLowerCase() === 's') {
|
||||||
@@ -408,11 +560,12 @@ export class CanvasInteractions {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||||
if (e.ctrlKey || e.metaKey) {
|
const mods = this.getModifierState(e);
|
||||||
|
if (mods.ctrl || mods.meta) {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'z':
|
case 'z':
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -427,6 +580,17 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvasLayers.copySelectedLayers();
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'v':
|
||||||
|
// Only handle internal clipboard paste here.
|
||||||
|
// If internal clipboard is empty, let the paste event bubble
|
||||||
|
// so handlePasteEvent can access e.clipboardData for system images.
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
} else {
|
||||||
|
// Don't preventDefault - let paste event fire for system clipboard
|
||||||
|
handled = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
break;
|
break;
|
||||||
@@ -439,7 +603,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = mods.shift ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||||
@@ -475,6 +639,8 @@ export class CanvasInteractions {
|
|||||||
handleKeyUp(e) {
|
handleKeyUp(e) {
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
if (e.key === 'Alt')
|
if (e.key === 'Alt')
|
||||||
@@ -494,6 +660,7 @@ export class CanvasInteractions {
|
|||||||
handleBlur() {
|
handleBlur() {
|
||||||
log.debug('Window lost focus, resetting key states.');
|
log.debug('Window lost focus, resetting key states.');
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
this.interaction.isAltPressed = false;
|
this.interaction.isAltPressed = false;
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
this.interaction.isSPressed = false;
|
this.interaction.isSPressed = false;
|
||||||
@@ -515,6 +682,16 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateCursor(worldCoords) {
|
updateCursor(worldCoords) {
|
||||||
|
// If actively rotating, show grabbing cursor
|
||||||
|
if (this.interaction.mode === 'rotating') {
|
||||||
|
this.canvas.canvas.style.cursor = 'grabbing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check if hovering over grab icon
|
||||||
|
if (this.interaction.hoveringGrabIcon) {
|
||||||
|
this.canvas.canvas.style.cursor = 'grab';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
const handleName = transformTarget.handle;
|
const handleName = transformTarget.handle;
|
||||||
@@ -539,7 +716,10 @@ export class CanvasInteractions {
|
|||||||
width: layer.width, height: layer.height,
|
width: layer.width, height: layer.height,
|
||||||
rotation: layer.rotation,
|
rotation: layer.rotation,
|
||||||
centerX: layer.x + layer.width / 2,
|
centerX: layer.x + layer.width / 2,
|
||||||
centerY: layer.y + layer.height / 2
|
centerY: layer.y + layer.height / 2,
|
||||||
|
originalWidth: layer.originalWidth,
|
||||||
|
originalHeight: layer.originalHeight,
|
||||||
|
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||||
};
|
};
|
||||||
this.interaction.dragStart = { ...worldCoords };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
if (handle === 'rot') {
|
if (handle === 'rot') {
|
||||||
@@ -559,15 +739,16 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
prepareForDrag(layer, worldCoords) {
|
prepareForDrag(layer, worldCoords) {
|
||||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||||
if (this.interaction.isCtrlPressed) {
|
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
|
||||||
|
const mods = this.getModifierState();
|
||||||
|
if (mods.ctrl || mods.meta) {
|
||||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
// Ctrl-clicking unselected layer: add to selection
|
||||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
}
|
}
|
||||||
else {
|
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
|
||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
// User can use right-click in layers panel to deselect individual layers
|
||||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||||
@@ -577,10 +758,9 @@ export class CanvasInteractions {
|
|||||||
this.interaction.mode = 'potential-drag';
|
this.interaction.mode = 'potential-drag';
|
||||||
this.interaction.dragStart = { ...worldCoords };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
}
|
}
|
||||||
startPanningOrClearSelection(e) {
|
startPanning(e, clearSelection = true) {
|
||||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
// Unified panning method - can optionally clear selection
|
||||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
this.interaction.mode = 'panning';
|
||||||
@@ -629,19 +809,16 @@ export class CanvasInteractions {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
startPanning(e) {
|
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
|
||||||
}
|
|
||||||
this.interaction.mode = 'panning';
|
|
||||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
|
||||||
}
|
|
||||||
panViewport(e) {
|
panViewport(e) {
|
||||||
const dx = e.clientX - this.interaction.panStart.x;
|
const dx = e.clientX - this.interaction.panStart.x;
|
||||||
const dy = e.clientY - this.interaction.panStart.y;
|
const dy = e.clientY - this.interaction.panStart.y;
|
||||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||||
|
// Update stroke overlay if mask tool is drawing during pan
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
@@ -692,58 +869,150 @@ export class CanvasInteractions {
|
|||||||
let mouseY = worldCoords.y;
|
let mouseY = worldCoords.y;
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||||
const snappedMouseX = snapToGrid(mouseX);
|
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
mouseX = snappedMouseX;
|
|
||||||
const snappedMouseY = snapToGrid(mouseY);
|
|
||||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
|
|
||||||
mouseY = snappedMouseY;
|
|
||||||
}
|
}
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
const rad = o.rotation * Math.PI / 180;
|
const rad = o.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
// Vector from anchor to mouse
|
||||||
const vecX = mouseX - anchor.x;
|
const vecX = mouseX - anchor.x;
|
||||||
const vecY = mouseY - anchor.y;
|
const vecY = mouseY - anchor.y;
|
||||||
let newWidth = vecX * cos + vecY * sin;
|
// Rotate vector to align with layer's local coordinates
|
||||||
let newHeight = vecY * cos - vecX * sin;
|
let localVecX = vecX * cos + vecY * sin;
|
||||||
if (isShiftPressed) {
|
let localVecY = vecY * cos - vecX * sin;
|
||||||
const originalAspectRatio = o.width / o.height;
|
// Determine sign based on handle
|
||||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||||
}
|
localVecX *= signX;
|
||||||
else {
|
localVecY *= signY;
|
||||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
// If not a corner handle, keep original dimension
|
||||||
}
|
|
||||||
}
|
|
||||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
|
||||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
|
||||||
newWidth *= signX;
|
|
||||||
newHeight *= signY;
|
|
||||||
if (signX === 0)
|
if (signX === 0)
|
||||||
newWidth = o.width;
|
localVecX = o.width;
|
||||||
if (signY === 0)
|
if (signY === 0)
|
||||||
newHeight = o.height;
|
localVecY = o.height;
|
||||||
if (newWidth < 10)
|
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||||
newWidth = 10;
|
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||||
if (newHeight < 10)
|
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||||
newHeight = 10;
|
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||||
layer.width = newWidth;
|
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||||
layer.height = newHeight;
|
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||||
const deltaW = newWidth - o.width;
|
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||||
const deltaH = newHeight - o.height;
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
const shiftX = (deltaW / 2) * signX;
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
const shiftY = (deltaH / 2) * signY;
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
let mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||||
const newCenterX = o.centerX + worldShiftX;
|
if (layer.flipH) {
|
||||||
const newCenterY = o.centerY + worldShiftY;
|
mouseDeltaX_local *= -1;
|
||||||
layer.x = newCenterX - layer.width / 2;
|
}
|
||||||
layer.y = newCenterY - layer.height / 2;
|
if (layer.flipV) {
|
||||||
|
mouseDeltaY_local *= -1;
|
||||||
|
}
|
||||||
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
|
const screenToImageScaleX = o.originalWidth / o.width;
|
||||||
|
const screenToImageScaleY = o.originalHeight / o.height;
|
||||||
|
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||||
|
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||||
|
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||||
|
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||||
|
const isFlippedH = layer.flipH;
|
||||||
|
const isFlippedV = layer.flipV;
|
||||||
|
if (handle?.includes('w')) {
|
||||||
|
if (isFlippedH)
|
||||||
|
newCropBounds.width += delta_image_x;
|
||||||
|
else {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handle?.includes('e')) {
|
||||||
|
if (isFlippedH) {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
newCropBounds.width += delta_image_x;
|
||||||
|
}
|
||||||
|
if (handle?.includes('n')) {
|
||||||
|
if (isFlippedV)
|
||||||
|
newCropBounds.height += delta_image_y;
|
||||||
|
else {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (handle?.includes('s')) {
|
||||||
|
if (isFlippedV) {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
newCropBounds.height += delta_image_y;
|
||||||
|
}
|
||||||
|
// Clamp crop bounds to stay within the original image and maintain minimum size
|
||||||
|
if (newCropBounds.width < 1) {
|
||||||
|
if (handle?.includes('w'))
|
||||||
|
newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
|
||||||
|
newCropBounds.width = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.height < 1) {
|
||||||
|
if (handle?.includes('n'))
|
||||||
|
newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
|
||||||
|
newCropBounds.height = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x < 0) {
|
||||||
|
newCropBounds.width += newCropBounds.x;
|
||||||
|
newCropBounds.x = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y < 0) {
|
||||||
|
newCropBounds.height += newCropBounds.y;
|
||||||
|
newCropBounds.y = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
|
||||||
|
newCropBounds.width = o.originalWidth - newCropBounds.x;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
|
||||||
|
newCropBounds.height = o.originalHeight - newCropBounds.y;
|
||||||
|
}
|
||||||
|
layer.cropBounds = newCropBounds;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||||
|
let newWidth = localVecX;
|
||||||
|
let newHeight = localVecY;
|
||||||
|
if (isShiftPressed) {
|
||||||
|
const originalAspectRatio = o.width / o.height;
|
||||||
|
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||||
|
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newWidth < 10)
|
||||||
|
newWidth = 10;
|
||||||
|
if (newHeight < 10)
|
||||||
|
newHeight = 10;
|
||||||
|
layer.width = newWidth;
|
||||||
|
layer.height = newHeight;
|
||||||
|
// Update position to keep anchor point fixed
|
||||||
|
const deltaW = layer.width - o.width;
|
||||||
|
const deltaH = layer.height - o.height;
|
||||||
|
const shiftX = (deltaW / 2) * signX;
|
||||||
|
const shiftY = (deltaH / 2) * signY;
|
||||||
|
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||||
|
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||||
|
const newCenterX = o.centerX + worldShiftX;
|
||||||
|
const newCenterY = o.centerY + worldShiftY;
|
||||||
|
layer.x = newCenterX - layer.width / 2;
|
||||||
|
layer.y = newCenterY - layer.height / 2;
|
||||||
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
||||||
@@ -751,7 +1020,7 @@ export class CanvasInteractions {
|
|||||||
if (!layer)
|
if (!layer)
|
||||||
return;
|
return;
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
||||||
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||||
@@ -916,10 +1185,13 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async handlePasteEvent(e) {
|
async handlePasteEvent(e) {
|
||||||
|
// Check if canvas is connected to DOM and visible
|
||||||
|
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldHandle = this.canvas.isMouseOver ||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
this.canvas.canvas.contains(document.activeElement) ||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
document.activeElement === this.canvas.canvas ||
|
document.activeElement === this.canvas.canvas;
|
||||||
document.activeElement === document.body;
|
|
||||||
if (!shouldHandle) {
|
if (!shouldHandle) {
|
||||||
log.debug("Paste event ignored - not focused on canvas");
|
log.debug("Paste event ignored - not focused on canvas");
|
||||||
return;
|
return;
|
||||||
@@ -960,4 +1232,168 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||||
}
|
}
|
||||||
|
// New methods for output area transformation
|
||||||
|
activateOutputAreaTransform() {
|
||||||
|
// Clear any existing interaction state before starting transform
|
||||||
|
this.resetInteractionState();
|
||||||
|
// Deactivate any active tools that might conflict
|
||||||
|
if (this.canvas.shapeTool.isActive) {
|
||||||
|
this.canvas.shapeTool.deactivate();
|
||||||
|
}
|
||||||
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
this.canvas.maskTool.deactivate();
|
||||||
|
}
|
||||||
|
// Clear selection to avoid confusion
|
||||||
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
|
// Set transform mode
|
||||||
|
this.interaction.mode = 'transformingOutputArea';
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
getOutputAreaHandle(worldCoords) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
const threshold = 10 / this.canvas.viewport.zoom;
|
||||||
|
// Define handle positions
|
||||||
|
const handles = {
|
||||||
|
'nw': { x: bounds.x, y: bounds.y },
|
||||||
|
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||||
|
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||||
|
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||||
|
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||||
|
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||||
|
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||||
|
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||||
|
};
|
||||||
|
for (const [name, pos] of Object.entries(handles)) {
|
||||||
|
const dx = worldCoords.x - pos.x;
|
||||||
|
const dy = worldCoords.y - pos.y;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) < threshold) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
startOutputAreaTransform(handle, worldCoords) {
|
||||||
|
this.interaction.outputAreaTransformHandle = handle;
|
||||||
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
this.interaction.transformOrigin = {
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
rotation: 0,
|
||||||
|
centerX: bounds.x + bounds.width / 2,
|
||||||
|
centerY: bounds.y + bounds.height / 2
|
||||||
|
};
|
||||||
|
// Set anchor point (opposite corner for resize)
|
||||||
|
const anchorMap = {
|
||||||
|
'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||||
|
'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||||
|
'ne': { x: bounds.x, y: bounds.y + bounds.height },
|
||||||
|
'e': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||||
|
'se': { x: bounds.x, y: bounds.y },
|
||||||
|
's': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||||
|
'sw': { x: bounds.x + bounds.width, y: bounds.y },
|
||||||
|
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||||
|
};
|
||||||
|
this.interaction.outputAreaTransformAnchor = anchorMap[handle];
|
||||||
|
}
|
||||||
|
resizeOutputAreaFromHandle(worldCoords, isShiftPressed) {
|
||||||
|
const o = this.interaction.transformOrigin;
|
||||||
|
if (!o)
|
||||||
|
return;
|
||||||
|
const handle = this.interaction.outputAreaTransformHandle;
|
||||||
|
const anchor = this.interaction.outputAreaTransformAnchor;
|
||||||
|
let newX = o.x;
|
||||||
|
let newY = o.y;
|
||||||
|
let newWidth = o.width;
|
||||||
|
let newHeight = o.height;
|
||||||
|
// Calculate new dimensions based on handle
|
||||||
|
if (handle?.includes('w')) {
|
||||||
|
const deltaX = worldCoords.x - anchor.x;
|
||||||
|
newWidth = Math.abs(deltaX);
|
||||||
|
newX = Math.min(worldCoords.x, anchor.x);
|
||||||
|
}
|
||||||
|
if (handle?.includes('e')) {
|
||||||
|
const deltaX = worldCoords.x - anchor.x;
|
||||||
|
newWidth = Math.abs(deltaX);
|
||||||
|
newX = Math.min(worldCoords.x, anchor.x);
|
||||||
|
}
|
||||||
|
if (handle?.includes('n')) {
|
||||||
|
const deltaY = worldCoords.y - anchor.y;
|
||||||
|
newHeight = Math.abs(deltaY);
|
||||||
|
newY = Math.min(worldCoords.y, anchor.y);
|
||||||
|
}
|
||||||
|
if (handle?.includes('s')) {
|
||||||
|
const deltaY = worldCoords.y - anchor.y;
|
||||||
|
newHeight = Math.abs(deltaY);
|
||||||
|
newY = Math.min(worldCoords.y, anchor.y);
|
||||||
|
}
|
||||||
|
// Maintain aspect ratio if shift is held
|
||||||
|
if (isShiftPressed && o.width > 0 && o.height > 0) {
|
||||||
|
const aspectRatio = o.width / o.height;
|
||||||
|
if (handle === 'n' || handle === 's') {
|
||||||
|
newWidth = newHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
else if (handle === 'e' || handle === 'w') {
|
||||||
|
newHeight = newWidth / aspectRatio;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Corner handles
|
||||||
|
const proposedRatio = newWidth / newHeight;
|
||||||
|
if (proposedRatio > aspectRatio) {
|
||||||
|
newHeight = newWidth / aspectRatio;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newWidth = newHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Snap to grid if Ctrl is held
|
||||||
|
if (this.interaction.isCtrlPressed) {
|
||||||
|
newX = snapToGrid(newX);
|
||||||
|
newY = snapToGrid(newY);
|
||||||
|
newWidth = snapToGrid(newWidth);
|
||||||
|
newHeight = snapToGrid(newHeight);
|
||||||
|
}
|
||||||
|
// Apply minimum size
|
||||||
|
if (newWidth < 10)
|
||||||
|
newWidth = 10;
|
||||||
|
if (newHeight < 10)
|
||||||
|
newHeight = 10;
|
||||||
|
// Update output area bounds temporarily for preview
|
||||||
|
this.canvas.outputAreaBounds = {
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight
|
||||||
|
};
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
updateOutputAreaTransformCursor(worldCoords) {
|
||||||
|
const handle = this.getOutputAreaHandle(worldCoords);
|
||||||
|
if (handle) {
|
||||||
|
const cursorMap = {
|
||||||
|
'n': 'ns-resize', 's': 'ns-resize',
|
||||||
|
'e': 'ew-resize', 'w': 'ew-resize',
|
||||||
|
'nw': 'nwse-resize', 'se': 'nwse-resize',
|
||||||
|
'ne': 'nesw-resize', 'sw': 'nesw-resize',
|
||||||
|
};
|
||||||
|
this.canvas.canvas.style.cursor = cursorMap[handle] || 'default';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.canvas.canvas.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalizeOutputAreaTransform() {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
// Update canvas size and mask tool
|
||||||
|
this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
|
||||||
|
// Update mask canvas for new output area
|
||||||
|
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||||
|
// Save state
|
||||||
|
this.canvas.saveState();
|
||||||
|
// Reset transform handle but keep transform mode active
|
||||||
|
this.interaction.outputAreaTransformHandle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { createCanvas } from "./utils/CommonUtils.js";
|
import { createCanvas } from "./utils/CommonUtils.js";
|
||||||
|
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||||
const log = createModuleLogger('CanvasLayersPanel');
|
const log = createModuleLogger('CanvasLayersPanel');
|
||||||
export class CanvasLayersPanel {
|
export class CanvasLayersPanel {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -18,6 +19,8 @@ export class CanvasLayersPanel {
|
|||||||
this.handleDrop = this.handleDrop.bind(this);
|
this.handleDrop = this.handleDrop.bind(this);
|
||||||
// Preload icons
|
// Preload icons
|
||||||
this.initializeIcons();
|
this.initializeIcons();
|
||||||
|
// Load CSS for layers panel
|
||||||
|
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||||
log.info('CanvasLayersPanel initialized');
|
log.info('CanvasLayersPanel initialized');
|
||||||
}
|
}
|
||||||
async initializeIcons() {
|
async initializeIcons() {
|
||||||
@@ -31,22 +34,15 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
createIconElement(toolName, size = 16) {
|
createIconElement(toolName, size = 16) {
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container';
|
||||||
width: ${size}px;
|
iconContainer.style.width = `${size}px`;
|
||||||
height: ${size}px;
|
iconContainer.style.height = `${size}px`;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
const icon = iconLoader.getIcon(toolName);
|
const icon = iconLoader.getIcon(toolName);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode();
|
const img = icon.cloneNode();
|
||||||
img.style.cssText = `
|
img.style.width = `${size}px`;
|
||||||
width: ${size}px;
|
img.style.height = `${size}px`;
|
||||||
height: ${size}px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
}
|
}
|
||||||
else if (icon instanceof HTMLCanvasElement) {
|
else if (icon instanceof HTMLCanvasElement) {
|
||||||
@@ -59,9 +55,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback text
|
// Fallback text
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||||
iconContainer.style.color = '#ffffff';
|
|
||||||
}
|
}
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
}
|
}
|
||||||
@@ -72,24 +68,15 @@ export class CanvasLayersPanel {
|
|||||||
else {
|
else {
|
||||||
// Create a "hidden" version of the visibility icon
|
// Create a "hidden" version of the visibility icon
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container visibility-hidden';
|
||||||
width: 16px;
|
iconContainer.style.width = '16px';
|
||||||
height: 16px;
|
iconContainer.style.height = '16px';
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
`;
|
|
||||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode();
|
const img = icon.cloneNode();
|
||||||
img.style.cssText = `
|
img.style.width = '16px';
|
||||||
width: 16px;
|
img.style.height = '16px';
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
}
|
}
|
||||||
else if (icon instanceof HTMLCanvasElement) {
|
else if (icon instanceof HTMLCanvasElement) {
|
||||||
@@ -103,9 +90,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback
|
// Fallback
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = 'H';
|
iconContainer.textContent = 'H';
|
||||||
iconContainer.style.fontSize = '10px';
|
iconContainer.style.fontSize = '10px';
|
||||||
iconContainer.style.color = '#888888';
|
|
||||||
}
|
}
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
}
|
}
|
||||||
@@ -116,6 +103,7 @@ export class CanvasLayersPanel {
|
|||||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="layers-panel-header">
|
<div class="layers-panel-header">
|
||||||
|
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||||
<span class="layers-panel-title">Layers</span>
|
<span class="layers-panel-title">Layers</span>
|
||||||
<div class="layers-panel-controls">
|
<div class="layers-panel-controls">
|
||||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||||
@@ -126,226 +114,40 @@ export class CanvasLayersPanel {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.layersContainer = this.container.querySelector('#layers-container');
|
this.layersContainer = this.container.querySelector('#layers-container');
|
||||||
this.injectStyles();
|
|
||||||
// Setup event listeners dla przycisków
|
// Setup event listeners dla przycisków
|
||||||
this.setupControlButtons();
|
this.setupControlButtons();
|
||||||
|
this.setupMasterVisibilityToggle();
|
||||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
this.container.addEventListener('keydown', (e) => {
|
this.container.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Handle Ctrl+C/V for layer copy/paste when panel has focus
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key.toLowerCase() === 'c') {
|
||||||
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
|
log.info('Layers copied from panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.key.toLowerCase() === 'v') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
log.info('Layers pasted from panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
log.debug('Panel structure created');
|
log.debug('Panel structure created');
|
||||||
return this.container;
|
return this.container;
|
||||||
}
|
}
|
||||||
injectStyles() {
|
|
||||||
const styleId = 'layers-panel-styles';
|
|
||||||
if (document.getElementById(styleId)) {
|
|
||||||
return; // Style już istnieją
|
|
||||||
}
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleId;
|
|
||||||
style.textContent = `
|
|
||||||
.layers-panel {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ffffff;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #3a3a3a;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-title {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn {
|
|
||||||
background: #3a3a3a;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:hover {
|
|
||||||
background: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:active {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.selected {
|
|
||||||
background: #2d5aa0 !important;
|
|
||||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.dragging {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.layer-thumbnail {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
|
||||||
background-size: 8px 8px;
|
|
||||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name.editing {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border: 1px solid #6a6a6a;
|
|
||||||
outline: none;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-insertion-line {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #4a7bc8;
|
|
||||||
border-radius: 1px;
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-track {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
log.debug('Styles injected');
|
|
||||||
}
|
|
||||||
setupControlButtons() {
|
setupControlButtons() {
|
||||||
if (!this.container)
|
if (!this.container)
|
||||||
return;
|
return;
|
||||||
@@ -359,6 +161,69 @@ export class CanvasLayersPanel {
|
|||||||
log.info('Delete layer button clicked');
|
log.info('Delete layer button clicked');
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
});
|
});
|
||||||
|
// Initial button state update
|
||||||
|
this.updateButtonStates();
|
||||||
|
}
|
||||||
|
setupMasterVisibilityToggle() {
|
||||||
|
if (!this.container)
|
||||||
|
return;
|
||||||
|
const toggleContainer = this.container.querySelector('.master-visibility-toggle');
|
||||||
|
if (!toggleContainer)
|
||||||
|
return;
|
||||||
|
const updateToggleState = () => {
|
||||||
|
const total = this.canvas.layers.length;
|
||||||
|
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
|
||||||
|
toggleContainer.innerHTML = '';
|
||||||
|
const checkboxContainer = document.createElement('div');
|
||||||
|
checkboxContainer.className = 'checkbox-container';
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.id = 'master-visibility-checkbox';
|
||||||
|
const customCheckbox = document.createElement('span');
|
||||||
|
customCheckbox.className = 'custom-checkbox';
|
||||||
|
checkboxContainer.appendChild(checkbox);
|
||||||
|
checkboxContainer.appendChild(customCheckbox);
|
||||||
|
if (visibleCount === 0) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
checkbox.indeterminate = false;
|
||||||
|
customCheckbox.classList.remove('checked', 'indeterminate');
|
||||||
|
}
|
||||||
|
else if (visibleCount === total) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
checkbox.indeterminate = false;
|
||||||
|
customCheckbox.classList.add('checked');
|
||||||
|
customCheckbox.classList.remove('indeterminate');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
checkbox.checked = false;
|
||||||
|
checkbox.indeterminate = true;
|
||||||
|
customCheckbox.classList.add('indeterminate');
|
||||||
|
customCheckbox.classList.remove('checked');
|
||||||
|
}
|
||||||
|
checkboxContainer.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let newVisible;
|
||||||
|
if (checkbox.indeterminate) {
|
||||||
|
newVisible = false; // hide all when mixed
|
||||||
|
}
|
||||||
|
else if (checkbox.checked) {
|
||||||
|
newVisible = false; // toggle to hide all
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newVisible = true; // toggle to show all
|
||||||
|
}
|
||||||
|
this.canvas.layers.forEach(layer => {
|
||||||
|
layer.visible = newVisible;
|
||||||
|
});
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.requestSaveState();
|
||||||
|
updateToggleState();
|
||||||
|
this.renderLayers();
|
||||||
|
});
|
||||||
|
toggleContainer.appendChild(checkboxContainer);
|
||||||
|
};
|
||||||
|
updateToggleState();
|
||||||
|
this._updateMasterVisibilityToggle = updateToggleState;
|
||||||
}
|
}
|
||||||
renderLayers() {
|
renderLayers() {
|
||||||
if (!this.layersContainer) {
|
if (!this.layersContainer) {
|
||||||
@@ -376,6 +241,8 @@ export class CanvasLayersPanel {
|
|||||||
if (this.layersContainer)
|
if (this.layersContainer)
|
||||||
this.layersContainer.appendChild(layerElement);
|
this.layersContainer.appendChild(layerElement);
|
||||||
});
|
});
|
||||||
|
if (this._updateMasterVisibilityToggle)
|
||||||
|
this._updateMasterVisibilityToggle();
|
||||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||||
}
|
}
|
||||||
createLayerElement(layer, index) {
|
createLayerElement(layer, index) {
|
||||||
@@ -448,6 +315,7 @@ export class CanvasLayersPanel {
|
|||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||||
this.canvas.updateSelection(newSelection);
|
this.canvas.updateSelection(newSelection);
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
layerRow.addEventListener('dblclick', (e) => {
|
layerRow.addEventListener('dblclick', (e) => {
|
||||||
@@ -480,6 +348,9 @@ export class CanvasLayersPanel {
|
|||||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
|
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
|
||||||
|
this.canvas.canvas.focus();
|
||||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||||
}
|
}
|
||||||
startEditingLayerName(nameElement, layer) {
|
startEditingLayerName(nameElement, layer) {
|
||||||
@@ -660,12 +531,29 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||||
|
*/
|
||||||
|
updateButtonStates() {
|
||||||
|
if (!this.container)
|
||||||
|
return;
|
||||||
|
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||||
|
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = !hasSelectedLayers;
|
||||||
|
deleteBtn.title = hasSelectedLayers
|
||||||
|
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||||
|
: 'No layers selected';
|
||||||
|
}
|
||||||
|
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||||
*/
|
*/
|
||||||
onSelectionChanged() {
|
onSelectionChanged() {
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.container && this.container.parentNode) {
|
if (this.container && this.container.parentNode) {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export class CanvasRenderer {
|
|||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Helper function to draw text with background at world coordinates
|
* Helper function to draw text with background at world coordinates
|
||||||
@@ -102,10 +105,12 @@ export class CanvasRenderer {
|
|||||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -136,12 +141,17 @@ export class CanvasRenderer {
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Draw grab icons for selected layers when hovering
|
||||||
|
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
|
||||||
|
this.drawGrabIcons(ctx);
|
||||||
|
}
|
||||||
this.drawCanvasOutline(ctx);
|
this.drawCanvasOutline(ctx);
|
||||||
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||||
this.renderInteractionElements(ctx);
|
this.renderInteractionElements(ctx);
|
||||||
this.canvas.shapeTool.render(ctx);
|
this.canvas.shapeTool.render(ctx);
|
||||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||||
|
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||||
this.renderLayerInfo(ctx);
|
this.renderLayerInfo(ctx);
|
||||||
// Update custom shape menu position and visibility
|
// Update custom shape menu position and visibility
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
@@ -158,6 +168,11 @@ export class CanvasRenderer {
|
|||||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||||
|
// Ensure overlay canvases are in DOM and properly sized
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
this.updateOverlaySize();
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
// Update Batch Preview UI positions
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||||
@@ -431,39 +446,76 @@ export class CanvasRenderer {
|
|||||||
drawSelectionFrame(ctx, layer) {
|
drawSelectionFrame(ctx, layer) {
|
||||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||||
ctx.strokeStyle = '#00ff00';
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
ctx.lineWidth = lineWidth;
|
// --- CROP MODE ---
|
||||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
ctx.lineWidth = lineWidth;
|
||||||
const halfW = layer.width / 2;
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
const halfH = layer.height / 2;
|
ctx.strokeStyle = '#007bff';
|
||||||
// Górna krawędź
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
// Prawa krawędź
|
ctx.setLineDash([]);
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
// 2. Draw solid blue line for the crop bounds
|
||||||
// Dolna krawędź
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
// Lewa krawędź
|
const s = layer.cropBounds;
|
||||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
ctx.setLineDash([]);
|
const cropRectW = s.width * layerScaleX;
|
||||||
ctx.beginPath();
|
const cropRectH = s.height * layerScaleY;
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||||
ctx.stroke();
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||||
// Rysuj uchwyty
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// --- TRANSFORM MODE ---
|
||||||
|
ctx.strokeStyle = '#00ff00'; // Green
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
const halfW = layer.width / 2;
|
||||||
|
const halfH = layer.height / 2;
|
||||||
|
// Draw adaptive solid green line for transform frame
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||||
|
// Draw line to rotation handle
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.beginPath();
|
||||||
|
const startY = layer.flipV ? halfH : -halfH;
|
||||||
|
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||||
|
ctx.moveTo(0, startY);
|
||||||
|
ctx.lineTo(0, endY);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// --- DRAW HANDLES (Unified Logic) ---
|
||||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
|
// Skip rotation handle in crop mode
|
||||||
|
if (layer.cropMode && key === 'rot')
|
||||||
|
continue;
|
||||||
const point = handles[key];
|
const point = handles[key];
|
||||||
ctx.beginPath();
|
// The handle position is already in world space.
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
// We need to convert it to the layer's local, un-rotated space.
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const dx = point.x - centerX;
|
||||||
|
const dy = point.y - centerY;
|
||||||
|
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||||
const rad = -layer.rotation * Math.PI / 180;
|
const rad = -layer.rotation * Math.PI / 180;
|
||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const cos = Math.cos(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const sin = Math.sin(rad);
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
// The context is already flipped. We need to flip the coordinates
|
||||||
|
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||||
|
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||||
|
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -546,4 +598,328 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
padding: 8
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||||
|
*/
|
||||||
|
initOverlay() {
|
||||||
|
// Setup overlay canvas to match main canvas
|
||||||
|
this.updateOverlaySize();
|
||||||
|
// Position overlay canvas on top of main canvas
|
||||||
|
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||||
|
this.canvas.overlayCanvas.style.left = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.top = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||||
|
// Add overlay to DOM when main canvas is added
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
log.debug('Overlay canvas initialized');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add overlay canvas to DOM if main canvas has a parent
|
||||||
|
*/
|
||||||
|
addOverlayToDOM() {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||||
|
log.debug('Overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update overlay canvas size to match main canvas
|
||||||
|
*/
|
||||||
|
updateOverlaySize() {
|
||||||
|
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||||
|
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||||
|
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clear overlay canvas
|
||||||
|
*/
|
||||||
|
clearOverlay() {
|
||||||
|
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||||
|
*/
|
||||||
|
initStrokeOverlay() {
|
||||||
|
// Create canvas if not created yet
|
||||||
|
if (!this.strokeOverlayCanvas) {
|
||||||
|
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||||
|
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx = ctx;
|
||||||
|
}
|
||||||
|
// Size match main canvas
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
// Position above main canvas but below cursor overlay
|
||||||
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
|
this.strokeOverlayCanvas.style.left = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||||
|
// Add to DOM
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
log.debug('Stroke overlay canvas initialized');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add stroke overlay canvas to DOM if needed
|
||||||
|
*/
|
||||||
|
addStrokeOverlayToDOM() {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||||
|
log.debug('Stroke overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Ensure stroke overlay size matches main canvas
|
||||||
|
*/
|
||||||
|
updateStrokeOverlaySize() {
|
||||||
|
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||||
|
this.strokeOverlayCanvas.width = w;
|
||||||
|
this.strokeOverlayCanvas.height = h;
|
||||||
|
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clear the stroke overlay
|
||||||
|
*/
|
||||||
|
clearMaskStrokeOverlay() {
|
||||||
|
if (!this.strokeOverlayCtx)
|
||||||
|
return;
|
||||||
|
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||||
|
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||||
|
*/
|
||||||
|
drawMaskStrokeSegment(startWorld, endWorld) {
|
||||||
|
// Ensure overlay is present and sized
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
const zoom = this.canvas.viewport.zoom;
|
||||||
|
const toScreen = (p) => ({
|
||||||
|
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||||
|
y: (p.y - this.canvas.viewport.y) * zoom
|
||||||
|
});
|
||||||
|
const startScreen = toScreen(startWorld);
|
||||||
|
const endScreen = toScreen(endWorld);
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||||
|
const hardness = this.canvas.maskTool.brushHardness;
|
||||||
|
const strength = this.canvas.maskTool.brushStrength;
|
||||||
|
// If strength is 0, don't draw anything
|
||||||
|
if (strength <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx.save();
|
||||||
|
// Draw line segment exactly as MaskTool does
|
||||||
|
this.strokeOverlayCtx.beginPath();
|
||||||
|
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||||
|
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||||
|
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||||
|
if (hardness === 1) {
|
||||||
|
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const innerRadius = brushRadius * hardness;
|
||||||
|
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||||
|
}
|
||||||
|
// Match line properties from MaskTool
|
||||||
|
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||||
|
this.strokeOverlayCtx.lineCap = 'round';
|
||||||
|
this.strokeOverlayCtx.lineJoin = 'round';
|
||||||
|
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.strokeOverlayCtx.stroke();
|
||||||
|
this.strokeOverlayCtx.restore();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Redraws the entire stroke overlay from world coordinates
|
||||||
|
* Used when viewport changes during drawing to maintain visual consistency
|
||||||
|
*/
|
||||||
|
redrawMaskStrokeOverlay(strokePoints) {
|
||||||
|
if (strokePoints.length < 2)
|
||||||
|
return;
|
||||||
|
// Clear the overlay first
|
||||||
|
this.clearMaskStrokeOverlay();
|
||||||
|
// Redraw all segments with current viewport
|
||||||
|
for (let i = 1; i < strokePoints.length; i++) {
|
||||||
|
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||||
|
* @param worldPoint World coordinates of cursor
|
||||||
|
*/
|
||||||
|
drawMaskBrushCursor(worldPoint) {
|
||||||
|
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||||
|
this.clearOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Update overlay size if needed
|
||||||
|
this.updateOverlaySize();
|
||||||
|
// Clear previous cursor
|
||||||
|
this.clearOverlay();
|
||||||
|
// Convert world coordinates to screen coordinates
|
||||||
|
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
|
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
|
// Get brush properties
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||||
|
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||||
|
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||||
|
// Save context state
|
||||||
|
this.canvas.overlayCtx.save();
|
||||||
|
// If strength is 0, just draw outline
|
||||||
|
if (brushStrength > 0) {
|
||||||
|
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||||
|
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
|
||||||
|
// Preview alpha - subtle to not obscure content
|
||||||
|
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||||
|
if (brushHardness === 1) {
|
||||||
|
// Hard brush - uniform fill within radius
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Soft brush - gradient fade matching actual brush
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
if (brushHardness > 0) {
|
||||||
|
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
}
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = gradient;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
// Draw outer circle (SIZE indicator)
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||||
|
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||||
|
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||||
|
// Visual feedback for hardness
|
||||||
|
if (brushHardness > 0.8) {
|
||||||
|
// Hard brush - solid line
|
||||||
|
this.canvas.overlayCtx.setLineDash([]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Soft brush - dashed line
|
||||||
|
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||||
|
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||||
|
}
|
||||||
|
this.canvas.overlayCtx.stroke();
|
||||||
|
// Center dot for small brushes
|
||||||
|
if (brushRadius < 5) {
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
// Restore context state
|
||||||
|
this.canvas.overlayCtx.restore();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update overlay position when viewport changes
|
||||||
|
*/
|
||||||
|
updateOverlayPosition() {
|
||||||
|
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||||
|
// Just ensure it's the right size
|
||||||
|
this.updateOverlaySize();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw grab icons in the center of selected layers
|
||||||
|
*/
|
||||||
|
drawGrabIcons(ctx) {
|
||||||
|
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0)
|
||||||
|
return;
|
||||||
|
const iconRadius = 20 / this.canvas.viewport.zoom;
|
||||||
|
const innerRadius = 12 / this.canvas.viewport.zoom;
|
||||||
|
selectedLayers.forEach((layer) => {
|
||||||
|
if (!layer.visible)
|
||||||
|
return;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
ctx.save();
|
||||||
|
// Draw outer circle (background)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
|
ctx.stroke();
|
||||||
|
// Draw hand/grab icon (simplified)
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
|
||||||
|
// Draw four dots representing grab points
|
||||||
|
const dotRadius = 2 / this.canvas.viewport.zoom;
|
||||||
|
const dotDistance = 6 / this.canvas.viewport.zoom;
|
||||||
|
// Top-left
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Top-right
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Bottom-left
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Bottom-right
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw transform handles for output area when in transform mode
|
||||||
|
*/
|
||||||
|
renderOutputAreaTransformHandles(ctx) {
|
||||||
|
if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||||
|
// Define handle positions
|
||||||
|
const handles = {
|
||||||
|
'nw': { x: bounds.x, y: bounds.y },
|
||||||
|
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||||
|
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||||
|
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||||
|
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||||
|
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||||
|
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||||
|
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||||
|
};
|
||||||
|
// Draw handles
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
for (const [name, pos] of Object.entries(handles)) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
// Draw a highlight around the output area
|
||||||
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
||||||
|
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ export class CanvasState {
|
|||||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||||
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
||||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
|
||||||
if (this.canvas.layers.length === 0) {
|
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||||
log.warn("No valid layers loaded, state may be corrupted.");
|
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
|
||||||
return false;
|
// Don't return false - allow empty canvas to be valid
|
||||||
}
|
}
|
||||||
this.canvas.updateSelectionAfterHistory();
|
this.canvas.updateSelectionAfterHistory();
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
@@ -200,6 +200,7 @@ export class CanvasState {
|
|||||||
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
||||||
if (typeof imageSrc === 'string') {
|
if (typeof imageSrc === 'string') {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||||
const newLayer = { ...layerData, image: img };
|
const newLayer = { ...layerData, image: img };
|
||||||
@@ -216,6 +217,7 @@ export class CanvasState {
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.drawImage(imageSrc, 0, 0);
|
ctx.drawImage(imageSrc, 0, 0);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||||
const newLayer = { ...layerData, image: img };
|
const newLayer = { ...layerData, image: img };
|
||||||
@@ -404,12 +406,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
}
|
}
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
@@ -420,12 +420,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
if (maskCtx) {
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
617
js/CanvasView.js
617
js/CanvasView.js
@@ -8,7 +8,7 @@ import { clearAllCanvasStates } from "./db.js";
|
|||||||
import { ImageCache } from "./ImageCache.js";
|
import { ImageCache } from "./ImageCache.js";
|
||||||
import { createCanvas } from "./utils/CommonUtils.js";
|
import { createCanvas } from "./utils/CommonUtils.js";
|
||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification, showSuccessNotification, showInfoNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification } from "./utils/NotificationUtils.js";
|
||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||||
const log = createModuleLogger('Canvas_view');
|
const log = createModuleLogger('Canvas_view');
|
||||||
@@ -17,6 +17,32 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
onStateChange: () => updateOutput(node, canvas)
|
onStateChange: () => updateOutput(node, canvas)
|
||||||
});
|
});
|
||||||
const imageCache = new ImageCache();
|
const imageCache = new ImageCache();
|
||||||
|
/**
|
||||||
|
* Helper function to update the icon of a switch component.
|
||||||
|
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||||
|
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||||
|
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||||
|
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||||
|
* @param fallbackTrue The text fallback for the 'true' state.
|
||||||
|
* @param fallbackFalse The text fallback for the 'false' state.
|
||||||
|
*/
|
||||||
|
const updateSwitchIcon = (knobIconEl, isChecked, iconToolTrue, iconToolFalse, fallbackTrue, fallbackFalse) => {
|
||||||
|
if (!knobIconEl)
|
||||||
|
return;
|
||||||
|
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||||
|
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||||
|
const icon = iconLoader.getIcon(iconTool);
|
||||||
|
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||||
|
if (icon instanceof HTMLImageElement) {
|
||||||
|
const clonedIcon = icon.cloneNode();
|
||||||
|
clonedIcon.style.width = '20px';
|
||||||
|
clonedIcon.style.height = '20px';
|
||||||
|
knobIconEl.appendChild(clonedIcon);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
knobIconEl.textContent = fallbackText;
|
||||||
|
}
|
||||||
|
};
|
||||||
const helpTooltip = $el("div.painter-tooltip", {
|
const helpTooltip = $el("div.painter-tooltip", {
|
||||||
id: `painter-help-tooltip-${node.id}`,
|
id: `painter-help-tooltip-${node.id}`,
|
||||||
});
|
});
|
||||||
@@ -72,7 +98,6 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}),
|
}),
|
||||||
$el("button.painter-button.icon-button", {
|
$el("button.painter-button.icon-button", {
|
||||||
textContent: "?",
|
textContent: "?",
|
||||||
title: "Show shortcuts",
|
|
||||||
onmouseenter: (e) => {
|
onmouseenter: (e) => {
|
||||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||||
showTooltip(e.target, content);
|
showTooltip(e.target, content);
|
||||||
@@ -151,34 +176,36 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("span.switch-icon")
|
$el("span.switch-icon")
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
// Helper function to get current tooltip content based on switch state
|
||||||
|
const getCurrentTooltipContent = () => {
|
||||||
|
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
||||||
|
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||||
|
};
|
||||||
|
// Helper function to update tooltip content if it's currently visible
|
||||||
|
const updateTooltipIfVisible = () => {
|
||||||
|
// Only update if tooltip is currently visible
|
||||||
|
if (helpTooltip.style.display === 'block') {
|
||||||
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
|
showTooltip(switchEl, tooltipContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
// Tooltip logic
|
// Tooltip logic
|
||||||
switchEl.addEventListener("mouseenter", (e) => {
|
switchEl.addEventListener("mouseenter", (e) => {
|
||||||
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
|
||||||
showTooltip(switchEl, tooltipContent);
|
showTooltip(switchEl, tooltipContent);
|
||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
// Dynamic icon and text update on toggle
|
// Dynamic icon update on toggle
|
||||||
const input = switchEl.querySelector('input[type="checkbox"]');
|
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
||||||
const updateSwitchView = (isClipspace) => {
|
input.addEventListener('change', () => {
|
||||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||||
const icon = iconLoader.getIcon(iconTool);
|
// Update tooltip content immediately after state change
|
||||||
if (icon instanceof HTMLImageElement) {
|
updateTooltipIfVisible();
|
||||||
knobIcon.innerHTML = '';
|
});
|
||||||
const clonedIcon = icon.cloneNode();
|
|
||||||
clonedIcon.style.width = '20px';
|
|
||||||
clonedIcon.style.height = '20px';
|
|
||||||
knobIcon.appendChild(clonedIcon);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
|
||||||
// Initial state
|
// Initial state
|
||||||
iconLoader.preloadToolIcons().then(() => {
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
updateSwitchView(isClipspace);
|
updateSwitchIcon(knobIcon, isClipspace, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||||
});
|
});
|
||||||
return switchEl;
|
return switchEl;
|
||||||
})()
|
})()
|
||||||
@@ -186,88 +213,32 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
$el("button.painter-button.requires-selection", {
|
||||||
|
textContent: "Auto Adjust Output",
|
||||||
|
title: "Automatically adjust output area to fit selected layers",
|
||||||
|
onclick: () => {
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0) {
|
||||||
|
showWarningNotification("Please select one or more layers first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
|
||||||
|
if (success) {
|
||||||
|
const bounds = canvas.outputAreaBounds;
|
||||||
|
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
showErrorNotification("Cannot calculate valid output area dimensions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
$el("button.painter-button", {
|
$el("button.painter-button", {
|
||||||
textContent: "Output Area Size",
|
textContent: "Output Area Size",
|
||||||
title: "Set the size of the output area",
|
title: "Transform output area - drag handles to resize",
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
const dialog = $el("div.painter-dialog", {
|
// Activate output area transform mode
|
||||||
style: {
|
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||||
position: 'fixed',
|
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
zIndex: '9999'
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
color: "white",
|
|
||||||
marginBottom: "10px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("label", {
|
|
||||||
style: {
|
|
||||||
marginRight: "5px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("span", {}, ["Width: "])
|
|
||||||
]),
|
|
||||||
$el("input", {
|
|
||||||
type: "number",
|
|
||||||
id: "canvas-width",
|
|
||||||
value: String(canvas.width),
|
|
||||||
min: "1",
|
|
||||||
max: "4096"
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
color: "white",
|
|
||||||
marginBottom: "10px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("label", {
|
|
||||||
style: {
|
|
||||||
marginRight: "5px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("span", {}, ["Height: "])
|
|
||||||
]),
|
|
||||||
$el("input", {
|
|
||||||
type: "number",
|
|
||||||
id: "canvas-height",
|
|
||||||
value: String(canvas.height),
|
|
||||||
min: "1",
|
|
||||||
max: "4096"
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
textAlign: "right"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("button", {
|
|
||||||
id: "cancel-size",
|
|
||||||
textContent: "Cancel"
|
|
||||||
}),
|
|
||||||
$el("button", {
|
|
||||||
id: "confirm-size",
|
|
||||||
textContent: "OK"
|
|
||||||
})
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
document.getElementById('confirm-size').onclick = () => {
|
|
||||||
const widthInput = document.getElementById('canvas-width');
|
|
||||||
const heightInput = document.getElementById('canvas-height');
|
|
||||||
const width = parseInt(widthInput.value) || canvas.width;
|
|
||||||
const height = parseInt(heightInput.value) || canvas.height;
|
|
||||||
canvas.setOutputAreaSize(width, height);
|
|
||||||
document.body.removeChild(dialog);
|
|
||||||
};
|
|
||||||
document.getElementById('cancel-size').onclick = () => {
|
|
||||||
document.body.removeChild(dialog);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
@@ -293,6 +264,50 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
(() => {
|
||||||
|
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||||
|
id: `crop-transform-switch-${node.id}`,
|
||||||
|
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||||
|
}, [
|
||||||
|
$el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: false,
|
||||||
|
onchange: (e) => {
|
||||||
|
const isCropMode = e.target.checked;
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0)
|
||||||
|
return;
|
||||||
|
selectedLayers.forEach((layer) => {
|
||||||
|
layer.cropMode = isCropMode;
|
||||||
|
if (isCropMode && !layer.cropBounds) {
|
||||||
|
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
canvas.saveState();
|
||||||
|
canvas.render();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("span.switch-track"),
|
||||||
|
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||||
|
$el("span.text-clipspace", {}, ["Crop"]),
|
||||||
|
$el("span.text-system", {}, ["Transform"])
|
||||||
|
]),
|
||||||
|
$el("span.switch-knob", {}, [
|
||||||
|
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}` })
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
});
|
||||||
|
// Initial state
|
||||||
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
|
updateSwitchIcon(knobIcon, false, // Initial state is transform
|
||||||
|
LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
});
|
||||||
|
return switchEl;
|
||||||
|
})(),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
@@ -328,11 +343,38 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const button = e.target.closest('.matting-button');
|
const button = e.target.closest('.matting-button');
|
||||||
if (button.classList.contains('loading'))
|
if (button.classList.contains('loading'))
|
||||||
return;
|
return;
|
||||||
const spinner = $el("div.matting-spinner");
|
|
||||||
button.appendChild(spinner);
|
|
||||||
button.classList.add('loading');
|
|
||||||
showInfoNotification("Starting background removal process...", 2000);
|
|
||||||
try {
|
try {
|
||||||
|
// First check if model is available
|
||||||
|
const modelCheckResponse = await fetch("/matting/check-model");
|
||||||
|
const modelStatus = await modelCheckResponse.json();
|
||||||
|
if (!modelStatus.available) {
|
||||||
|
switch (modelStatus.reason) {
|
||||||
|
case 'missing_dependency':
|
||||||
|
showErrorNotification(modelStatus.message, 8000);
|
||||||
|
return;
|
||||||
|
case 'not_downloaded':
|
||||||
|
showWarningNotification("The matting model needs to be downloaded first. This will happen automatically when you proceed (requires internet connection).", 5000);
|
||||||
|
// Ask user if they want to proceed with download
|
||||||
|
if (!confirm("The matting model needs to be downloaded (about 1GB). This is a one-time download. Do you want to proceed?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showInfoNotification("Downloading matting model... This may take a few minutes.", 10000);
|
||||||
|
break;
|
||||||
|
case 'corrupted':
|
||||||
|
showErrorNotification(modelStatus.message, 8000);
|
||||||
|
return;
|
||||||
|
case 'error':
|
||||||
|
showErrorNotification(`Error checking model: ${modelStatus.message}`, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Proceed with matting
|
||||||
|
const spinner = $el("div.matting-spinner");
|
||||||
|
button.appendChild(spinner);
|
||||||
|
button.classList.add('loading');
|
||||||
|
if (modelStatus.available) {
|
||||||
|
showInfoNotification("Starting background removal process...", 2000);
|
||||||
|
}
|
||||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||||
throw new Error("Please select exactly one image layer for matting.");
|
throw new Error("Please select exactly one image layer for matting.");
|
||||||
}
|
}
|
||||||
@@ -348,7 +390,20 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||||
if (result && result.error) {
|
if (result && result.error) {
|
||||||
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
|
// Handle specific error types
|
||||||
|
if (result.error === "Network Connection Error") {
|
||||||
|
showErrorNotification("Failed to download the matting model. Please check your internet connection and try again.", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (result.error === "Matting Model Error") {
|
||||||
|
showErrorNotification(result.details || "Model loading error. Please check the console for details.", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (result.error === "Dependency Not Found") {
|
||||||
|
showErrorNotification(result.details || "Missing required dependencies.", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorMsg = `${result.error}: ${result.details || 'Check console'}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
@@ -359,6 +414,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
delete newLayer.imageId;
|
delete newLayer.imageId;
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
|
// Invalidate processed image cache when layer image changes (matting)
|
||||||
|
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||||
canvas.render();
|
canvas.render();
|
||||||
canvas.saveState();
|
canvas.saveState();
|
||||||
showSuccessNotification("Background removed successfully!");
|
showSuccessNotification("Background removed successfully!");
|
||||||
@@ -366,11 +423,16 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
catch (error) {
|
catch (error) {
|
||||||
log.error("Matting error:", error);
|
log.error("Matting error:", error);
|
||||||
const errorMessage = error.message || "An unknown error occurred.";
|
const errorMessage = error.message || "An unknown error occurred.";
|
||||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
if (!errorMessage.includes("Network Connection Error") &&
|
||||||
|
!errorMessage.includes("Matting Model Error") &&
|
||||||
|
!errorMessage.includes("Dependency Not Found")) {
|
||||||
|
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
button.classList.remove('loading');
|
button.classList.remove('loading');
|
||||||
if (button.contains(spinner)) {
|
const spinner = button.querySelector('.matting-spinner');
|
||||||
|
if (spinner && button.contains(spinner)) {
|
||||||
button.removeChild(spinner);
|
button.removeChild(spinner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +457,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("div.painter-button-group", { id: "mask-controls" }, [
|
$el("div.painter-button-group", { id: "mask-controls" }, [
|
||||||
$el("label.clipboard-switch.mask-switch", {
|
$el("label.clipboard-switch.mask-switch", {
|
||||||
id: `toggle-mask-switch-${node.id}`,
|
id: `toggle-mask-switch-${node.id}`,
|
||||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||||
|
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||||
}, [
|
}, [
|
||||||
$el("input", {
|
$el("input", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
@@ -480,6 +543,25 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
setTimeout(() => canvas.render(), 0);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||||
|
$el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }),
|
||||||
|
$el("input", {
|
||||||
|
id: "preview-opacity-slider",
|
||||||
|
type: "range",
|
||||||
|
min: "0",
|
||||||
|
max: "1",
|
||||||
|
step: "0.05",
|
||||||
|
value: "0.5",
|
||||||
|
oninput: (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||||
|
const valueEl = document.getElementById('preview-opacity-value');
|
||||||
|
if (valueEl)
|
||||||
|
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("div.slider-value", { id: "preview-opacity-value" }, ["50%"])
|
||||||
|
]),
|
||||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||||
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
@@ -629,19 +711,38 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const updateButtonStates = () => {
|
const updateButtonStates = () => {
|
||||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn) => {
|
// --- Handle Standard Buttons ---
|
||||||
const button = btn;
|
controlPanel.querySelectorAll('.requires-selection').forEach((el) => {
|
||||||
if (button.textContent === 'Fuse') {
|
if (el.tagName === 'BUTTON') {
|
||||||
button.disabled = selectionCount < 2;
|
if (el.textContent === 'Fuse') {
|
||||||
}
|
el.disabled = selectionCount < 2;
|
||||||
else {
|
}
|
||||||
button.disabled = !hasSelection;
|
else {
|
||||||
|
el.disabled = !hasSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const mattingBtn = controlPanel.querySelector('.matting-button');
|
const mattingBtn = controlPanel.querySelector('.matting-button');
|
||||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||||
mattingBtn.disabled = selectionCount !== 1;
|
mattingBtn.disabled = selectionCount !== 1;
|
||||||
}
|
}
|
||||||
|
// --- Handle Crop/Transform Switch ---
|
||||||
|
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`);
|
||||||
|
if (switchEl) {
|
||||||
|
const input = switchEl.querySelector('input');
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||||
|
const isDisabled = !hasSelection;
|
||||||
|
switchEl.classList.toggle('disabled', isDisabled);
|
||||||
|
input.disabled = isDisabled;
|
||||||
|
if (!isDisabled) {
|
||||||
|
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||||
|
if (input.checked !== isCropMode) {
|
||||||
|
input.checked = isCropMode;
|
||||||
|
}
|
||||||
|
// Update icon view
|
||||||
|
updateSwitchIcon(knobIcon, isCropMode, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||||
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
||||||
@@ -783,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (controlsElement) {
|
if (controlsElement) {
|
||||||
resizeObserver.observe(controlsElement);
|
resizeObserver.observe(controlsElement);
|
||||||
}
|
}
|
||||||
|
// Watch the canvas container itself to detect size changes and fix canvas dimensions
|
||||||
|
const canvasContainerResizeObserver = new ResizeObserver(() => {
|
||||||
|
// Force re-read of canvas dimensions on next render
|
||||||
|
canvas.render();
|
||||||
|
});
|
||||||
|
canvasContainerResizeObserver.observe(canvasContainer);
|
||||||
canvas.canvas.addEventListener('focus', () => {
|
canvas.canvas.addEventListener('focus', () => {
|
||||||
canvasContainer.classList.add('has-focus');
|
canvasContainer.classList.add('has-focus');
|
||||||
});
|
});
|
||||||
@@ -799,7 +906,9 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
||||||
let backdrop = null;
|
let backdrop = null;
|
||||||
let originalParent = null;
|
let originalParent = null;
|
||||||
@@ -888,7 +997,11 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!window.canvasExecutionStates) {
|
if (!window.canvasExecutionStates) {
|
||||||
window.canvasExecutionStates = new Map();
|
window.canvasExecutionStates = new Map();
|
||||||
}
|
}
|
||||||
node.canvasWidget = canvas;
|
// Store the entire widget object, not just the canvas
|
||||||
|
node.canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
if (canvas.canvasLayersPanel) {
|
if (canvas.canvasLayersPanel) {
|
||||||
@@ -905,7 +1018,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (canvas && canvas.setPreviewVisibility) {
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
if (node.graph && node.graph.canvas) {
|
if (node.graph && node.graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -921,7 +1034,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
const canvasNodeInstances = new Map();
|
const canvasNodeInstances = new Map();
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "Comfy.CanvasNode",
|
name: "Comfy.LayerForgeNode",
|
||||||
init() {
|
init() {
|
||||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||||
const originalQueuePrompt = app.queuePrompt;
|
const originalQueuePrompt = app.queuePrompt;
|
||||||
@@ -931,13 +1044,20 @@ app.registerExtension({
|
|||||||
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
|
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
|
||||||
const sendPromises = [];
|
const sendPromises = [];
|
||||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
const node = app.graph.getNodeById(nodeId);
|
||||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
if (!node) {
|
||||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||||
canvasNodeInstances.delete(nodeId);
|
canvasNodeInstances.delete(nodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip bypassed nodes
|
||||||
|
if (node.mode === 4) {
|
||||||
|
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||||
|
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -955,7 +1075,9 @@ app.registerExtension({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeType.comfyClass === "CanvasNode") {
|
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||||
|
// Map to track pending copy sources across node ID changes
|
||||||
|
const pendingCopySources = new Map();
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||||
@@ -984,9 +1106,181 @@ app.registerExtension({
|
|||||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
this.canvasWidget = canvasWidget;
|
||||||
|
// Check if this node has a pending copy source (from onConfigure)
|
||||||
|
// Check both the current ID and -1 (temporary ID during paste)
|
||||||
|
let sourceNodeId = pendingCopySources.get(this.id);
|
||||||
|
if (!sourceNodeId) {
|
||||||
|
sourceNodeId = pendingCopySources.get(-1);
|
||||||
|
if (sourceNodeId) {
|
||||||
|
// Transfer from -1 to the real ID and clear -1
|
||||||
|
pendingCopySources.delete(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sourceNodeId && sourceNodeId !== this.id) {
|
||||||
|
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
|
||||||
|
// Clear the flag
|
||||||
|
pendingCopySources.delete(this.id);
|
||||||
|
// Copy the canvas state now that the widget is initialized
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||||
|
let sourceState = await getCanvasState(String(sourceNodeId));
|
||||||
|
// If source node doesn't exist (cross-workflow paste), try clipboard
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
|
||||||
|
sourceState = await getCanvasState('__clipboard__');
|
||||||
|
}
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found in clipboard either`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setCanvasState(String(this.id), sourceState);
|
||||||
|
await canvasWidget.canvas.loadInitialState();
|
||||||
|
log.info(`Canvas state copied successfully to node ${this.id}`);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error(`Error copying canvas state:`, error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (type, index, connected, link_info) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = this.canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if (canvas.maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (message) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
const canvas = this.canvasWidget?.canvas || this.canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
nodeType.prototype.onRemoved = function () {
|
nodeType.prototype.onRemoved = function () {
|
||||||
@@ -1016,6 +1310,47 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
return onRemoved?.apply(this, arguments);
|
return onRemoved?.apply(this, arguments);
|
||||||
};
|
};
|
||||||
|
// Handle copy/paste - save canvas state when copying
|
||||||
|
const originalSerialize = nodeType.prototype.serialize;
|
||||||
|
nodeType.prototype.serialize = function () {
|
||||||
|
const data = originalSerialize ? originalSerialize.apply(this) : {};
|
||||||
|
// Store a reference to the source node ID so we can copy layer data
|
||||||
|
data.sourceNodeId = this.id;
|
||||||
|
log.debug(`Serializing node ${this.id} for copy`);
|
||||||
|
// Store canvas state in a clipboard entry for cross-workflow paste
|
||||||
|
// This happens async but that's fine since paste happens later
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||||
|
const sourceState = await getCanvasState(String(this.id));
|
||||||
|
if (sourceState) {
|
||||||
|
// Store in a special "clipboard" entry
|
||||||
|
await setCanvasState('__clipboard__', sourceState);
|
||||||
|
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error('Error storing canvas state to clipboard:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
// Handle copy/paste - load canvas state from source node when pasting
|
||||||
|
const originalConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = async function (data) {
|
||||||
|
if (originalConfigure) {
|
||||||
|
originalConfigure.apply(this, [data]);
|
||||||
|
}
|
||||||
|
// Store the source node ID in the map (persists across node ID changes)
|
||||||
|
// This will be picked up later in onAdded when the canvas widget is ready
|
||||||
|
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
|
||||||
|
const existingSource = pendingCopySources.get(this.id);
|
||||||
|
if (!existingSource) {
|
||||||
|
pendingCopySources.set(this.id, data.sourceNodeId);
|
||||||
|
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||||
// FIRST: Call original to let other extensions add their options
|
// FIRST: Call original to let other extensions add their options
|
||||||
@@ -1099,8 +1434,8 @@ app.registerExtension({
|
|||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
log.info("Opening LayerForge canvas in MaskEditor");
|
log.info("Opening LayerForge canvas in MaskEditor");
|
||||||
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
|
if (self.canvasWidget && self.canvasWidget.canvas) {
|
||||||
await self.canvasWidget.startMaskEditor(null, true);
|
await self.canvasWidget.canvas.startMaskEditor(null, true);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.error("Canvas widget not available");
|
log.error("Canvas widget not available");
|
||||||
@@ -1117,9 +1452,9 @@ app.registerExtension({
|
|||||||
content: "Open Image",
|
content: "Open Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1135,9 +1470,9 @@ app.registerExtension({
|
|||||||
content: "Open Image with Mask Alpha",
|
content: "Open Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1153,9 +1488,9 @@ app.registerExtension({
|
|||||||
content: "Copy Image",
|
content: "Copy Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const item = new ClipboardItem({ 'image/png': blob });
|
const item = new ClipboardItem({ 'image/png': blob });
|
||||||
@@ -1172,9 +1507,9 @@ app.registerExtension({
|
|||||||
content: "Copy Image with Mask Alpha",
|
content: "Copy Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const item = new ClipboardItem({ 'image/png': blob });
|
const item = new ClipboardItem({ 'image/png': blob });
|
||||||
@@ -1191,9 +1526,9 @@ app.registerExtension({
|
|||||||
content: "Save Image",
|
content: "Save Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -1214,9 +1549,9 @@ app.registerExtension({
|
|||||||
content: "Save Image with Mask Alpha",
|
content: "Save Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!self.canvasWidget)
|
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||||
return;
|
return;
|
||||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob)
|
if (!blob)
|
||||||
return;
|
return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -424,7 +424,6 @@ export class MaskEditorIntegration {
|
|||||||
boundsPos: { x: bounds.x, y: bounds.y },
|
boundsPos: { x: bounds.x, y: bounds.y },
|
||||||
maskSize: { width: bounds.width, height: bounds.height }
|
maskSize: { width: bounds.width, height: bounds.height }
|
||||||
});
|
});
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(this.canvas, this.node, true);
|
await updateNodePreview(this.canvas, this.node, true);
|
||||||
|
|||||||
244
js/MaskTool.js
244
js/MaskTool.js
@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('Mask_tool');
|
const log = createModuleLogger('Mask_tool');
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
constructor(canvasInstance, callbacks = {}) {
|
constructor(canvasInstance, callbacks = {}) {
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
||||||
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
||||||
this.canvasInstance = canvasInstance;
|
this.canvasInstance = canvasInstance;
|
||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -28,8 +32,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
||||||
@@ -79,8 +84,27 @@ export class MaskTool {
|
|||||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Getters for brush properties
|
||||||
|
get brushStrength() {
|
||||||
|
return this._brushStrength;
|
||||||
|
}
|
||||||
|
get brushHardness() {
|
||||||
|
return this._brushHardness;
|
||||||
|
}
|
||||||
|
get previewOpacity() {
|
||||||
|
return this._previewOpacity;
|
||||||
|
}
|
||||||
setBrushHardness(hardness) {
|
setBrushHardness(hardness) {
|
||||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||||
|
}
|
||||||
|
setPreviewOpacity(opacity) {
|
||||||
|
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||||
|
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||||
|
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||||
|
}
|
||||||
|
// Trigger canvas render to update mask display opacity
|
||||||
|
this.canvasInstance.render();
|
||||||
}
|
}
|
||||||
initMaskCanvas() {
|
initMaskCanvas() {
|
||||||
// Initialize chunked system
|
// Initialize chunked system
|
||||||
@@ -671,16 +695,17 @@ export class MaskTool {
|
|||||||
this.brushSize = Math.max(1, size);
|
this.brushSize = Math.max(1, size);
|
||||||
}
|
}
|
||||||
setBrushStrength(strength) {
|
setBrushStrength(strength) {
|
||||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||||
}
|
}
|
||||||
handleMouseDown(worldCoords, viewCoords) {
|
handleMouseDown(worldCoords, viewCoords) {
|
||||||
if (!this.isActive)
|
if (!this.isActive)
|
||||||
return;
|
return;
|
||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
this.draw(worldCoords);
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
handleMouseMove(worldCoords, viewCoords) {
|
handleMouseMove(worldCoords, viewCoords) {
|
||||||
@@ -689,14 +714,69 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing)
|
if (!this.isActive || !this.isDrawing)
|
||||||
return;
|
return;
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints.push(worldCoords);
|
||||||
this.draw(worldCoords);
|
// Draw interpolated segments for smooth strokes without gaps
|
||||||
|
if (this.lastPosition) {
|
||||||
|
// Calculate distance between last and current position
|
||||||
|
const dx = worldCoords.x - this.lastPosition.x;
|
||||||
|
const dy = worldCoords.y - this.lastPosition.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
// If distance is small, just draw a single segment
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Interpolate points for smooth drawing without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Interpolates points between two positions to create smooth strokes without gaps
|
||||||
|
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||||
|
*/
|
||||||
|
interpolatePoints(start, end, distance) {
|
||||||
|
const points = [];
|
||||||
|
// Calculate number of interpolated points based on brush size
|
||||||
|
// More points = smoother line
|
||||||
|
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||||
|
const numSteps = Math.ceil(distance / stepSize);
|
||||||
|
// Always include start point
|
||||||
|
points.push(start);
|
||||||
|
// Interpolate intermediate points
|
||||||
|
for (let i = 1; i < numSteps; i++) {
|
||||||
|
const t = i / numSteps;
|
||||||
|
points.push({
|
||||||
|
x: start.x + (end.x - start.x) * t,
|
||||||
|
y: start.y + (end.y - start.y) * t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Always include end point
|
||||||
|
points.push(end);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Called when viewport changes during drawing to update stroke overlay
|
||||||
|
* This ensures the stroke preview scales correctly with zoom changes
|
||||||
|
*/
|
||||||
|
handleViewportChange() {
|
||||||
|
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||||
|
// Redraw the entire stroke overlay with new viewport settings
|
||||||
|
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
handleMouseLeave() {
|
handleMouseLeave() {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
handleMouseEnter() {
|
handleMouseEnter() {
|
||||||
this.previewVisible = true;
|
this.previewVisible = true;
|
||||||
@@ -706,10 +786,15 @@ export class MaskTool {
|
|||||||
return;
|
return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
|
// Commit the stroke from overlay to actual mask chunks
|
||||||
|
this.commitStrokeToChunks();
|
||||||
|
// Clear stroke overlay and reset state
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// After drawing is complete, update active canvas to show all chunks
|
||||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
this.updateActiveMaskCanvas(true); // Force full update
|
||||||
this.completeMaskOperation();
|
this.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
}
|
}
|
||||||
@@ -724,6 +809,38 @@ export class MaskTool {
|
|||||||
// This prevents unnecessary recomposition during drawing
|
// This prevents unnecessary recomposition during drawing
|
||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Commits the current stroke from overlay to actual mask chunks
|
||||||
|
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||||
|
*/
|
||||||
|
commitStrokeToChunks() {
|
||||||
|
if (this.currentStrokePoints.length < 2) {
|
||||||
|
return; // Need at least 2 points for a stroke
|
||||||
|
}
|
||||||
|
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||||
|
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||||
|
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||||
|
const startPoint = this.currentStrokePoints[i - 1];
|
||||||
|
const endPoint = this.currentStrokePoints[i];
|
||||||
|
// Calculate distance between points
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
// Small distance - draw single segment
|
||||||
|
this.drawOnChunks(startPoint, endPoint);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Large distance - interpolate for smooth line without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||||
|
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Draws a line between two world coordinates on the appropriate chunks
|
* Draws a line between two world coordinates on the appropriate chunks
|
||||||
*/
|
*/
|
||||||
@@ -767,13 +884,13 @@ export class MaskTool {
|
|||||||
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
||||||
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this._brushHardness;
|
||||||
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
|
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
|
||||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -805,28 +922,17 @@ export class MaskTool {
|
|||||||
return true; // For now, always draw - more precise intersection can be added later
|
return true; // For now, always draw - more precise intersection can be added later
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
|
||||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
|
||||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
|
||||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
|
||||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
|
||||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
|
||||||
// During drawing, only update affected chunks that are active for performance
|
|
||||||
if (this.isDrawing) {
|
|
||||||
// Use throttled partial update for active chunks only
|
|
||||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
this.updateActiveMaskCanvas(true);
|
||||||
}
|
}
|
||||||
|
// During drawing, we don't update chunks at all - overlay handles preview
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
||||||
@@ -903,18 +1009,12 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
drawBrushPreview(viewCoords) {
|
drawBrushPreview(viewCoords) {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
this.previewCtx.beginPath();
|
|
||||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
|
||||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
||||||
this.previewCtx.lineWidth = 1;
|
|
||||||
this.previewCtx.setLineDash([2, 4]);
|
|
||||||
this.previewCtx.stroke();
|
|
||||||
}
|
}
|
||||||
clearPreview() {
|
clearPreview() {
|
||||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||||
@@ -1252,6 +1352,23 @@ export class MaskTool {
|
|||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas) {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
getMask() {
|
getMask() {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1345,13 +1462,44 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = !this.isOverlayVisible;
|
this.isOverlayVisible = !this.isOverlayVisible;
|
||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
setMask(image) {
|
setMask(image, isFromInputMask = false) {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
if (isFromInputMask) {
|
||||||
// Add the new mask using the chunk system
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
this.addMask(image);
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Clears mask data in a specific area by clearing affected chunks
|
* Clears mask data in a specific area by clearing affected chunks
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
|||||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||||
import { convertToImage } from "./utils/ImageUtils.js";
|
import { convertToImage } from "./utils/ImageUtils.js";
|
||||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||||
|
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||||
const log = createModuleLogger('SAMDetectorIntegration');
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
/**
|
/**
|
||||||
* SAM Detector Integration for LayerForge
|
* SAM Detector Integration for LayerForge
|
||||||
@@ -241,35 +242,61 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Try to reload the image with a fresh request
|
// Try to reload the image with a fresh request
|
||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
await new Promise((resolve, reject) => {
|
// For data URLs, just ensure the image is loaded
|
||||||
const img = new Image();
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
img.crossOrigin = "anonymous";
|
await new Promise((resolve, reject) => {
|
||||||
img.onload = () => {
|
const img = new Image();
|
||||||
// Copy the loaded image data to the original image
|
img.onload = () => {
|
||||||
resultImage.src = img.src;
|
resultImage.width = img.width;
|
||||||
resultImage.width = img.width;
|
resultImage.height = img.height;
|
||||||
resultImage.height = img.height;
|
log.debug("Data URL image loaded successfully", {
|
||||||
log.debug("SAM result image reloaded successfully", {
|
width: img.width,
|
||||||
width: img.width,
|
height: img.height
|
||||||
height: img.height,
|
});
|
||||||
originalSrc: originalSrc,
|
resolve(img);
|
||||||
newSrc: img.src
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
}
|
||||||
img.onerror = (error) => {
|
else {
|
||||||
log.error("Failed to reload SAM result image", {
|
// For regular URLs, add cache-busting parameter
|
||||||
originalSrc: originalSrc,
|
const url = new URL(originalSrc);
|
||||||
newSrc: url.toString(),
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -289,27 +316,37 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
// Show success notification
|
// Show success notification
|
||||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||||
@@ -324,6 +361,8 @@ async function handleSAMDetectorResult(node, resultImage) {
|
|||||||
node.samOriginalImgSrc = null;
|
node.samOriginalImgSrc = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Store original onClipspaceEditorSave function to restore later
|
||||||
|
let originalOnClipspaceEditorSave = null;
|
||||||
// Function to setup SAM Detector hook in menu options
|
// Function to setup SAM Detector hook in menu options
|
||||||
export function setupSAMDetectorHook(node, options) {
|
export function setupSAMDetectorHook(node, options) {
|
||||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
@@ -337,18 +376,56 @@ export function setupSAMDetectorHook(node, options) {
|
|||||||
try {
|
try {
|
||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if (node.canvasWidget && node.canvasWidget.canvas) {
|
if (node.canvasWidget) {
|
||||||
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = node.canvasWidget;
|
||||||
// Use ImageUploadUtils to upload canvas
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
node.clipspaceImg = uploadResult.imageElement;
|
node.clipspaceImg = uploadResult.imageElement;
|
||||||
|
// Ensure proper clipspace structure for updated ComfyUI
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
ComfyApp.clipspace = {};
|
||||||
|
}
|
||||||
|
// Set up clipspace with proper indices
|
||||||
|
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
// Copy to ComfyUI clipspace
|
// Copy to ComfyUI clipspace
|
||||||
ComfyApp.copyToClipspace(node);
|
ComfyApp.copyToClipspace(node);
|
||||||
|
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||||
|
if (!originalOnClipspaceEditorSave) {
|
||||||
|
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||||
|
ComfyApp.onClipspaceEditorSave = function () {
|
||||||
|
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||||
|
// Use the unified clipspace validation function
|
||||||
|
const isValid = validateAndFixClipspace();
|
||||||
|
if (!isValid) {
|
||||||
|
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Call the original function
|
||||||
|
if (originalOnClipspaceEditorSave) {
|
||||||
|
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||||
|
}
|
||||||
|
// Restore the original function after use
|
||||||
|
if (originalOnClipspaceEditorSave) {
|
||||||
|
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||||
|
originalOnClipspaceEditorSave = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
// Start monitoring for SAM Detector results
|
// Start monitoring for SAM Detector results
|
||||||
startSAMDetectorMonitoring(node);
|
startSAMDetectorMonitoring(node);
|
||||||
log.info("Canvas automatically sent to clipspace and monitoring started");
|
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||||
|
|||||||
170
js/css/blend_mode_menu.css
Normal file
170
js/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* Blend Mode Menu Styles */
|
||||||
|
#blend-mode-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-bar {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: move;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-content {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-container {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-label {
|
||||||
|
color: white;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option.active {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
border-color: #3a76d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Crop mode button styling */
|
||||||
|
.painter-button#crop-mode-btn {
|
||||||
|
background-color: #444;
|
||||||
|
border-color: #555;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn.primary {
|
||||||
|
background-color: #0080ff;
|
||||||
|
border-color: #0070e0;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn.primary:hover {
|
||||||
|
background-color: #1090ff;
|
||||||
|
border-color: #0080ff;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn:hover {
|
||||||
|
background-color: #555;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.painter-button.success {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
@@ -187,7 +213,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -306,6 +332,25 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled state for switch */
|
||||||
|
.clipboard-switch.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #3a3a3a !important; /* Override gradient */
|
||||||
|
border-color: #4a4a4a !important;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-knob {
|
||||||
|
background-color: #4a4a4a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-labels {
|
||||||
|
color: #777 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -593,7 +638,7 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 111;
|
z-index: 999999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
309
js/css/layers_panel.css
Normal file
309
js/css/layers_panel.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/* Layers Panel Styles */
|
||||||
|
.layers-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container .custom-checkbox {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:checked ~ .custom-checkbox {
|
||||||
|
background-color: #3a76d6;
|
||||||
|
border-color: #3a76d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container .custom-checkbox::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
left: 5px;
|
||||||
|
top: 1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:checked ~ .custom-checkbox::after {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:indeterminate ~ .custom-checkbox {
|
||||||
|
background-color: #3a76d6;
|
||||||
|
border-color: #3a76d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: white;
|
||||||
|
border: none;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:active {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #666666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.selected {
|
||||||
|
background: #2d5aa0 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name.editing {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border: 1px solid #6a6a6a;
|
||||||
|
outline: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-insertion-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #4a7bc8;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container styles */
|
||||||
|
.layers-panel .icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.fallback-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createModuleLogger } from "./LoggerUtils.js";
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||||
|
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "../../../scripts/api.js";
|
import { api } from "../../../scripts/api.js";
|
||||||
// @ts-ignore
|
|
||||||
import { ComfyApp } from "../../../scripts/app.js";
|
|
||||||
const log = createModuleLogger('ClipboardManager');
|
const log = createModuleLogger('ClipboardManager');
|
||||||
export class ClipboardManager {
|
export class ClipboardManager {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
@@ -19,6 +18,7 @@ export class ClipboardManager {
|
|||||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
log.info("Found layers in internal clipboard, pasting layers");
|
log.info("Found layers in internal clipboard, pasting layers");
|
||||||
this.canvas.canvasLayers.pasteLayers();
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
showInfoNotification("Layers pasted from internal clipboard");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (preference === 'clipspace') {
|
if (preference === 'clipspace') {
|
||||||
@@ -28,9 +28,20 @@ export class ClipboardManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
log.info("No image found in ComfyUI Clipspace");
|
log.info("No image found in ComfyUI Clipspace");
|
||||||
|
// Don't show error here, will try system clipboard next
|
||||||
}
|
}
|
||||||
log.info("Attempting paste from system clipboard");
|
log.info("Attempting paste from system clipboard");
|
||||||
return await this.trySystemClipboardPaste(addMode);
|
const systemSuccess = await this.trySystemClipboardPaste(addMode);
|
||||||
|
if (!systemSuccess) {
|
||||||
|
// No valid image found in any clipboard
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
showWarningNotification("No valid image found in Clipspace or system clipboard");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
showWarningNotification("No valid image found in clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemSuccess;
|
||||||
}, 'ClipboardManager.handlePaste');
|
}, 'ClipboardManager.handlePaste');
|
||||||
/**
|
/**
|
||||||
* Attempts to paste from ComfyUI Clipspace
|
* Attempts to paste from ComfyUI Clipspace
|
||||||
@@ -39,7 +50,12 @@ export class ClipboardManager {
|
|||||||
*/
|
*/
|
||||||
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
||||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
// Use the unified clipspace validation and paste function
|
||||||
|
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
||||||
|
if (!pasteSuccess) {
|
||||||
|
log.debug("Safe clipspace paste failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
const clipspaceImage = this.canvas.node.imgs[0];
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
if (clipspaceImage && clipspaceImage.src) {
|
if (clipspaceImage && clipspaceImage.src) {
|
||||||
@@ -47,6 +63,7 @@ export class ClipboardManager {
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from Clipspace");
|
||||||
};
|
};
|
||||||
img.src = clipspaceImage.src;
|
img.src = clipspaceImage.src;
|
||||||
return true;
|
return true;
|
||||||
@@ -92,6 +109,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from backend response");
|
log.info("Successfully loaded image from backend response");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image loaded from file path");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
@@ -127,6 +145,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from system clipboard");
|
log.info("Successfully loaded image from system clipboard");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from system clipboard");
|
||||||
};
|
};
|
||||||
if (event.target?.result) {
|
if (event.target?.result) {
|
||||||
img.src = event.target.result;
|
img.src = event.target.result;
|
||||||
@@ -169,11 +188,22 @@ export class ClipboardManager {
|
|||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
log.debug("Found text in clipboard:", text);
|
log.debug("Found text in clipboard:", text);
|
||||||
if (text && this.isValidImagePath(text)) {
|
if (text) {
|
||||||
log.info("Found valid image path in clipboard:", text);
|
// Check if it's a data URI (base64 encoded image)
|
||||||
const success = await this.loadImageFromPath(text, addMode);
|
if (this.isDataURI(text)) {
|
||||||
if (success) {
|
log.info("Found data URI in clipboard");
|
||||||
return true;
|
const success = await this.loadImageFromDataURI(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if it's a regular file path or URL
|
||||||
|
else if (this.isValidImagePath(text)) {
|
||||||
|
log.info("Found valid image path in clipboard:", text);
|
||||||
|
const success = await this.loadImageFromPath(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +214,48 @@ export class ClipboardManager {
|
|||||||
log.debug("No images or valid image paths found in system clipboard");
|
log.debug("No images or valid image paths found in system clipboard");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Checks if a text string is a data URI (base64 encoded image)
|
||||||
|
* @param {string} text - The text to check
|
||||||
|
* @returns {boolean} - True if the text is a data URI
|
||||||
|
*/
|
||||||
|
isDataURI(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if it starts with data:image
|
||||||
|
return text.trim().startsWith('data:image/');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Loads an image from a data URI (base64 encoded image)
|
||||||
|
* @param {string} dataURI - The data URI to load
|
||||||
|
* @param {AddMode} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async loadImageFromDataURI(dataURI, addMode) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from data URI");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from clipboard (base64)");
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load image from data URI");
|
||||||
|
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
img.src = dataURI;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error loading data URI:", error);
|
||||||
|
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Validates if a text string is a valid image file path or URL
|
* Validates if a text string is a valid image file path or URL
|
||||||
* @param {string} text - The text to validate
|
* @param {string} text - The text to validate
|
||||||
@@ -248,10 +320,12 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from URL");
|
log.info("Successfully loaded image from URL");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image loaded from URL");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
log.warn("Failed to load image from URL:", filePath);
|
log.warn("Failed to load image from URL:", filePath);
|
||||||
|
showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
};
|
};
|
||||||
img.src = filePath;
|
img.src = filePath;
|
||||||
@@ -309,6 +383,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from file picker");
|
log.info("Successfully loaded image from file picker");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image loaded from selected file");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
|||||||
99
js/utils/ClipspaceUtils.js
Normal file
99
js/utils/ClipspaceUtils.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../../scripts/app.js";
|
||||||
|
const log = createModuleLogger('ClipspaceUtils');
|
||||||
|
/**
|
||||||
|
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||||
|
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||||
|
*/
|
||||||
|
export function validateAndFixClipspace() {
|
||||||
|
log.debug("Validating and fixing clipspace structure");
|
||||||
|
// Check if clipspace exists
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
log.debug("ComfyUI clipspace is not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate clipspace structure
|
||||||
|
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||||
|
log.debug("ComfyUI clipspace has no images");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log.debug("Current clipspace state:", {
|
||||||
|
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||||
|
});
|
||||||
|
// Ensure required indices are set
|
||||||
|
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace selectedIndex to 0");
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace combinedIndex to 0");
|
||||||
|
}
|
||||||
|
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
|
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||||
|
}
|
||||||
|
// Ensure indices are within bounds
|
||||||
|
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||||
|
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
// Verify the image at combinedIndex exists and has src
|
||||||
|
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!combinedImg || !combinedImg.src) {
|
||||||
|
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||||
|
// Try to use the first available image
|
||||||
|
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||||
|
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = i;
|
||||||
|
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Final check - if still no valid image found
|
||||||
|
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!finalImg || !finalImg.src) {
|
||||||
|
log.error("No valid images found in clipspace after attempting fixes");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("Final clipspace structure:", {
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||||
|
* @param {any} node - The ComfyUI node to paste to
|
||||||
|
* @returns {boolean} - True if paste was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export function safeClipspacePaste(node) {
|
||||||
|
log.debug("Attempting safe clipspace paste");
|
||||||
|
if (!validateAndFixClipspace()) {
|
||||||
|
log.debug("Clipspace validation failed, cannot paste");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ComfyApp.pasteFromClipspace(node);
|
||||||
|
log.debug("Successfully called pasteFromClipspace");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error calling pasteFromClipspace:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,13 +19,19 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
SETTINGS: 'settings',
|
SETTINGS: 'settings',
|
||||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||||
CLIPSPACE: 'clipspace',
|
CLIPSPACE: 'clipspace',
|
||||||
|
CROP: 'crop',
|
||||||
|
TRANSFORM: 'transform',
|
||||||
};
|
};
|
||||||
// SVG Icons for LayerForge tools
|
// SVG Icons for LayerForge tools
|
||||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||||
|
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
|
||||||
|
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
|
||||||
const LAYERFORGE_TOOL_ICONS = {
|
const LAYERFORGE_TOOL_ICONS = {
|
||||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
|
||||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||||
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
|
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
|
||||||
@@ -54,7 +60,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
|||||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||||
};
|
};
|
||||||
export class IconLoader {
|
export class IconLoader {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-ignore
|
||||||
import { api } from "../../../scripts/api.js";
|
import { api } from "../../../scripts/api.js";
|
||||||
import { createModuleLogger } from "./LoggerUtils.js";
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||||
|
|||||||
@@ -314,3 +314,102 @@ export function canvasToMaskImage(canvas) {
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image, targetWidth, targetHeight) {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create scaled image context");
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor, mode = 'rgb') {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
let lum;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm))
|
||||||
|
norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData) {
|
||||||
|
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error("Could not create canvas context");
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createModuleLogger } from "./LoggerUtils.js";
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
const log = createModuleLogger('NotificationUtils');
|
const log = createModuleLogger('NotificationUtils');
|
||||||
|
// Store active notifications for deduplication
|
||||||
|
const activeNotifications = new Map();
|
||||||
/**
|
/**
|
||||||
* Utility functions for showing notifications to the user
|
* Utility functions for showing notifications to the user
|
||||||
*/
|
*/
|
||||||
@@ -8,10 +10,50 @@ const log = createModuleLogger('NotificationUtils');
|
|||||||
* @param message - The message to show
|
* @param message - The message to show
|
||||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||||
* @param duration - Duration in milliseconds (default: 3000)
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param type - Type of notification
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
|
||||||
*/
|
*/
|
||||||
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info") {
|
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info", deduplicate = false) {
|
||||||
// Remove any existing prefix to avoid double prefixing
|
// Remove any existing prefix to avoid double prefixing
|
||||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||||
|
// If deduplication is enabled, check if this message already exists
|
||||||
|
if (deduplicate) {
|
||||||
|
const existingNotification = activeNotifications.get(message);
|
||||||
|
if (existingNotification) {
|
||||||
|
log.debug(`Notification already exists, refreshing timer: ${message}`);
|
||||||
|
// Clear existing timeout
|
||||||
|
if (existingNotification.timeout !== null) {
|
||||||
|
clearTimeout(existingNotification.timeout);
|
||||||
|
}
|
||||||
|
// Find the progress bar and restart its animation
|
||||||
|
const progressBar = existingNotification.element.querySelector('div[style*="animation"]');
|
||||||
|
if (progressBar) {
|
||||||
|
// Reset animation
|
||||||
|
progressBar.style.animation = 'none';
|
||||||
|
// Force reflow
|
||||||
|
void progressBar.offsetHeight;
|
||||||
|
// Restart animation
|
||||||
|
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||||
|
}
|
||||||
|
// Set new timeout
|
||||||
|
const newTimeout = window.setTimeout(() => {
|
||||||
|
const notification = existingNotification.element;
|
||||||
|
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||||
|
notification.addEventListener('animationend', () => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
activeNotifications.delete(message);
|
||||||
|
const container = document.getElementById('lf-notification-container');
|
||||||
|
if (container && container.children.length === 0) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, duration);
|
||||||
|
existingNotification.timeout = newTimeout;
|
||||||
|
return; // Don't create a new notification
|
||||||
|
}
|
||||||
|
}
|
||||||
// Type-specific config
|
// Type-specific config
|
||||||
const config = {
|
const config = {
|
||||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||||
@@ -148,6 +190,10 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
|||||||
body.classList.add('notification-scrollbar');
|
body.classList.add('notification-scrollbar');
|
||||||
let dismissTimeout = null;
|
let dismissTimeout = null;
|
||||||
const closeNotification = () => {
|
const closeNotification = () => {
|
||||||
|
// Remove from active notifications map if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
activeNotifications.delete(message);
|
||||||
|
}
|
||||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||||
notification.addEventListener('animationend', () => {
|
notification.addEventListener('animationend', () => {
|
||||||
if (notification.parentNode) {
|
if (notification.parentNode) {
|
||||||
@@ -171,40 +217,77 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
|||||||
progressBar.style.transform = computedStyle.transform;
|
progressBar.style.transform = computedStyle.transform;
|
||||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||||
};
|
};
|
||||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
notification.addEventListener('mouseenter', () => {
|
||||||
notification.addEventListener('mouseleave', startDismissTimer);
|
pauseAndRewindTimer();
|
||||||
|
// Update stored timeout if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
const stored = activeNotifications.get(message);
|
||||||
|
if (stored) {
|
||||||
|
stored.timeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notification.addEventListener('mouseleave', () => {
|
||||||
|
startDismissTimer();
|
||||||
|
// Update stored timeout if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
const stored = activeNotifications.get(message);
|
||||||
|
if (stored) {
|
||||||
|
stored.timeout = dismissTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
startDismissTimer();
|
startDismissTimer();
|
||||||
|
// Store notification if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||||
|
}
|
||||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows a success notification
|
* Shows a success notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showSuccessNotification(message, duration = 3000) {
|
export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
|
||||||
showNotification(message, undefined, duration, "success");
|
showNotification(message, undefined, duration, "success", deduplicate);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows an error notification
|
* Shows an error notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 5000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showErrorNotification(message, duration = 5000) {
|
export function showErrorNotification(message, duration = 5000, deduplicate = false) {
|
||||||
showNotification(message, undefined, duration, "error");
|
showNotification(message, undefined, duration, "error", deduplicate);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows an info notification
|
* Shows an info notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showInfoNotification(message, duration = 3000) {
|
export function showInfoNotification(message, duration = 3000, deduplicate = false) {
|
||||||
showNotification(message, undefined, duration, "info");
|
showNotification(message, undefined, duration, "info", deduplicate);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows a warning notification
|
* Shows a warning notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showWarningNotification(message, duration = 3000) {
|
export function showWarningNotification(message, duration = 3000, deduplicate = false) {
|
||||||
showNotification(message, undefined, duration, "warning");
|
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows an alert notification
|
* Shows an alert notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showAlertNotification(message, duration = 3000) {
|
export function showAlertNotification(message, duration = 3000, deduplicate = false) {
|
||||||
showNotification(message, undefined, duration, "alert");
|
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Shows a sequence of all notification types for debugging purposes.
|
* Shows a sequence of all notification types for debugging purposes.
|
||||||
@@ -214,7 +297,7 @@ export function showAllNotificationTypes(message) {
|
|||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showNotification(notificationMessage, undefined, 3000, type);
|
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||||
}, index * 400); // Stagger the notifications
|
}, index * 400); // Stagger the notifications
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.5.0"
|
version = "1.5.11"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,14 @@ export class BatchPreviewManager {
|
|||||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||||
if (this.maskWasVisible) {
|
if (this.maskWasVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.remove('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.remove('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '0.5';
|
iconContainer.style.opacity = '0.5';
|
||||||
}
|
}
|
||||||
@@ -218,10 +222,14 @@ export class BatchPreviewManager {
|
|||||||
|
|
||||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||||
this.canvas.maskTool.toggleOverlayVisibility();
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||||
if (toggleBtn) {
|
if (toggleSwitch) {
|
||||||
toggleBtn.classList.add('primary');
|
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
if (checkbox) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
}
|
||||||
|
toggleSwitch.classList.add('primary');
|
||||||
|
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||||
if (iconContainer) {
|
if (iconContainer) {
|
||||||
iconContainer.style.opacity = '1';
|
iconContainer.style.opacity = '1';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,18 @@ export class Canvas {
|
|||||||
node: ComfyNode;
|
node: ComfyNode;
|
||||||
offscreenCanvas: HTMLCanvasElement;
|
offscreenCanvas: HTMLCanvasElement;
|
||||||
offscreenCtx: CanvasRenderingContext2D | null;
|
offscreenCtx: CanvasRenderingContext2D | null;
|
||||||
|
overlayCanvas: HTMLCanvasElement;
|
||||||
|
overlayCtx: CanvasRenderingContext2D;
|
||||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||||
onViewportChange: (() => void) | null;
|
onViewportChange: (() => void) | null;
|
||||||
onStateChange: (() => void) | undefined;
|
onStateChange: (() => void) | undefined;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
lastLoadedLinkId?: number;
|
||||||
|
lastLoadedMaskLinkId?: number;
|
||||||
previewVisible: boolean;
|
previewVisible: boolean;
|
||||||
requestSaveState: () => void;
|
requestSaveState: () => void;
|
||||||
viewport: Viewport;
|
viewport: Viewport;
|
||||||
@@ -122,10 +129,22 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
this.offscreenCtx = offscreenCtx;
|
||||||
|
|
||||||
|
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||||
|
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||||
|
alpha: true,
|
||||||
|
willReadFrequently: false
|
||||||
|
});
|
||||||
|
if (!overlayCtx) throw new Error("Could not create overlay canvas context");
|
||||||
|
this.overlayCanvas = overlayCanvas;
|
||||||
|
this.overlayCtx = overlayCtx;
|
||||||
|
|
||||||
this.canvasContainer = null;
|
this.canvasContainer = null;
|
||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.pendingInputDataCheck = null;
|
||||||
|
this.inputDataLoaded = false;
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
|
|
||||||
this.requestSaveState = () => {};
|
this.requestSaveState = () => {};
|
||||||
@@ -471,6 +490,11 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
|
// Check for input data when execution starts, but don't reset the flag
|
||||||
|
log.debug('Execution started, checking for input data...');
|
||||||
|
// On start, only allow images; mask should load on mask-connect or after execution completes
|
||||||
|
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
@@ -494,6 +518,10 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
|
// Always check for input data after execution completes
|
||||||
|
log.debug('Execution success, checking for input data...');
|
||||||
|
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
|
||||||
|
|
||||||
if (getAutoRefreshValue()) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
|
|
||||||
@@ -550,8 +578,8 @@ export class Canvas {
|
|||||||
* Inicjalizuje podstawowe właściwości canvas
|
* Inicjalizuje podstawowe właściwości canvas
|
||||||
*/
|
*/
|
||||||
initCanvas() {
|
initCanvas() {
|
||||||
this.canvas.width = this.width;
|
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||||
this.canvas.height = this.height;
|
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||||
this.canvas.style.border = '1px solid black';
|
this.canvas.style.border = '1px solid black';
|
||||||
this.canvas.style.maxWidth = '100%';
|
this.canvas.style.maxWidth = '100%';
|
||||||
this.canvas.style.backgroundColor = '#606060';
|
this.canvas.style.backgroundColor = '#606060';
|
||||||
|
|||||||
637
src/CanvasIO.ts
637
src/CanvasIO.ts
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||||
|
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||||
import type { Canvas } from './Canvas';
|
import type { Canvas } from './Canvas';
|
||||||
import type { Layer, Shape } from './types';
|
import type { Layer, Shape } from './types';
|
||||||
|
|
||||||
@@ -216,11 +217,34 @@ export class CanvasIO {
|
|||||||
|
|
||||||
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
||||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||||
|
|
||||||
|
// Check if layers have valid images loaded, with retry logic
|
||||||
|
const maxRetries = 5;
|
||||||
|
const retryDelay = 200;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
|
||||||
|
|
||||||
|
if (layersWithoutImages.length === 0) {
|
||||||
|
break; // All images loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === 0) {
|
||||||
|
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
} else {
|
||||||
|
// Last attempt failed
|
||||||
|
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||||
|
|
||||||
if (!imageBlob || !maskBlob) {
|
if (!imageBlob || !maskBlob) {
|
||||||
throw new Error("Failed to generate canvas or mask blobs");
|
throw new Error("Failed to generate canvas or mask blobs");
|
||||||
}
|
}
|
||||||
@@ -269,7 +293,12 @@ export class CanvasIO {
|
|||||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||||
|
|
||||||
|
|
||||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
throw new Error(
|
||||||
|
`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||||
|
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||||
|
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||||
|
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,22 +306,12 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.debug("Adding input to canvas:", { inputImage });
|
log.debug("Adding input to canvas:", { inputImage });
|
||||||
|
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
// Use unified tensorToImageData for RGB image
|
||||||
if (!tempCtx) throw new Error("Could not create temp context");
|
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||||
|
if (!imageData) throw new Error("Failed to convert input image tensor");
|
||||||
|
|
||||||
const imgData = new ImageData(
|
// Create HTMLImageElement from ImageData
|
||||||
new Uint8ClampedArray(inputImage.data),
|
const image = await createImageFromImageData(imageData);
|
||||||
inputImage.width,
|
|
||||||
inputImage.height
|
|
||||||
);
|
|
||||||
tempCtx.putImageData(imgData, 0, 0);
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
image.onload = resolve;
|
|
||||||
image.onerror = reject;
|
|
||||||
image.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
|
|
||||||
const bounds = this.canvas.outputAreaBounds;
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
const scale = Math.min(
|
const scale = Math.min(
|
||||||
@@ -328,23 +347,10 @@ export class CanvasIO {
|
|||||||
throw new Error("Invalid tensor data");
|
throw new Error("Invalid tensor data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
const imageData = tensorToImageData(tensor, 'rgb');
|
||||||
if (!ctx) throw new Error("Could not create canvas context");
|
if (!imageData) throw new Error("Failed to convert tensor to image data");
|
||||||
|
|
||||||
const imageData = new ImageData(
|
return await createImageFromImageData(imageData);
|
||||||
new Uint8ClampedArray(tensor.data),
|
|
||||||
tensor.width,
|
|
||||||
tensor.height
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Error converting tensor to image:", error);
|
log.error("Error converting tensor to image:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -367,6 +373,16 @@ export class CanvasIO {
|
|||||||
try {
|
try {
|
||||||
log.info("Starting node data initialization...");
|
log.info("Starting node data initialization...");
|
||||||
|
|
||||||
|
// First check for input data from the backend (new feature)
|
||||||
|
await this.checkForInputData();
|
||||||
|
|
||||||
|
// If we've already loaded input data, don't continue with old initialization
|
||||||
|
if (this.canvas.inputDataLoaded) {
|
||||||
|
log.debug("Input data already loaded, skipping old initialization");
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
||||||
log.debug("Node or inputs not ready");
|
log.debug("Node or inputs not ready");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
@@ -374,6 +390,14 @@ export class CanvasIO {
|
|||||||
|
|
||||||
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
||||||
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
||||||
|
|
||||||
|
// Check if we already loaded this link
|
||||||
|
if (this.canvas.lastLoadedLinkId === imageLinkId) {
|
||||||
|
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
||||||
|
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
@@ -384,6 +408,9 @@ export class CanvasIO {
|
|||||||
log.debug("Image data not available yet");
|
log.debug("Image data not available yet");
|
||||||
return this.scheduleDataCheck();
|
return this.scheduleDataCheck();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No input connected, mark as initialized to stop repeated checks
|
||||||
|
this.canvas.dataInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
||||||
@@ -402,6 +429,439 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkForInputData(options?: { allowImage?: boolean; allowMask?: boolean; reason?: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const nodeId = this.canvas.node.id;
|
||||||
|
const allowImage = options?.allowImage ?? true;
|
||||||
|
const allowMask = options?.allowMask ?? true;
|
||||||
|
const reason = options?.reason ?? 'unspecified';
|
||||||
|
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
|
||||||
|
|
||||||
|
// Track loaded links separately for image and mask
|
||||||
|
let imageLoaded = false;
|
||||||
|
let maskLoaded = false;
|
||||||
|
let imageChanged = false;
|
||||||
|
|
||||||
|
// First, try to get data from connected node's output if available (IMAGES)
|
||||||
|
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const linkId = this.canvas.node.inputs[0].link;
|
||||||
|
const graph = (this.canvas.node as any).graph;
|
||||||
|
|
||||||
|
// Always check if images have changed first
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[linkId];
|
||||||
|
if (link) {
|
||||||
|
const sourceNode = graph.getNodeById(link.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// Create current batch identifier (all image sources combined)
|
||||||
|
const currentBatchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => img.src).join('|');
|
||||||
|
|
||||||
|
// Check if this is the same link we loaded before
|
||||||
|
if (this.canvas.lastLoadedLinkId === linkId) {
|
||||||
|
// Same link, check if images actually changed
|
||||||
|
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
|
||||||
|
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
|
||||||
|
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
|
||||||
|
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
|
||||||
|
imageChanged = true;
|
||||||
|
// Clear the inputDataLoaded flag to force reload from backend
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
// Clear the lastLoadedImageSrc to force reload
|
||||||
|
this.canvas.lastLoadedImageSrc = undefined;
|
||||||
|
// Clear backend data to force fresh load
|
||||||
|
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
|
||||||
|
.then(() => log.debug("Backend input data cleared due to image change"))
|
||||||
|
.catch(err => log.error("Failed to clear backend data:", err));
|
||||||
|
} else {
|
||||||
|
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
|
||||||
|
imageLoaded = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Different link or first load
|
||||||
|
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
|
||||||
|
imageChanged = false; // It's not a change, it's a new link
|
||||||
|
imageLoaded = false; // Need to load
|
||||||
|
// Reset the inputDataLoaded flag for new link
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageLoaded || imageChanged) {
|
||||||
|
// Reset the inputDataLoaded flag when images change
|
||||||
|
if (imageChanged) {
|
||||||
|
this.canvas.inputDataLoaded = false;
|
||||||
|
log.info("Resetting inputDataLoaded flag due to image change");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((this.canvas.node as any).graph) {
|
||||||
|
const graph2 = (this.canvas.node as any).graph;
|
||||||
|
const link2 = graph2.links[linkId];
|
||||||
|
if (link2) {
|
||||||
|
const sourceNode = graph2.getNodeById(link2.origin_id);
|
||||||
|
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
|
||||||
|
// The connected node has images in its output - handle multiple images (batch)
|
||||||
|
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
|
||||||
|
|
||||||
|
// Create a combined source identifier for batch detection
|
||||||
|
const batchImageSrcs = sourceNode.imgs.map((img: HTMLImageElement) => img.src).join('|');
|
||||||
|
|
||||||
|
// Mark this link and batch sources as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = linkId;
|
||||||
|
this.canvas.lastLoadedImageSrc = batchImageSrcs;
|
||||||
|
|
||||||
|
// Don't clear layers - just add new ones
|
||||||
|
if (imageChanged) {
|
||||||
|
log.info("Image change detected, will add new layers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine add mode
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
|
||||||
|
// Add all images from the batch as separate layers
|
||||||
|
for (let i = 0; i < sourceNode.imgs.length; i++) {
|
||||||
|
const img = sourceNode.imgs[i];
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{ name: `Batch Image ${i + 1}` }, // Give each layer a unique name
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
imageLoaded = true;
|
||||||
|
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mask input separately (from nodeOutputs) ONLY when allowed
|
||||||
|
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||||
|
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
|
||||||
|
// Check if we already loaded this mask link
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
|
||||||
|
log.debug(`Mask link ${maskLinkId} already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
} else {
|
||||||
|
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
|
||||||
|
const graph = (this.canvas.node as any).graph;
|
||||||
|
let maskOutput = null;
|
||||||
|
|
||||||
|
if (graph) {
|
||||||
|
const link = graph.links[maskLinkId];
|
||||||
|
if (link && link.origin_id) {
|
||||||
|
// Use origin_id to get the actual node output
|
||||||
|
const nodeOutput = (window as any).app?.nodeOutputs?.[link.origin_id];
|
||||||
|
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
|
||||||
|
|
||||||
|
if (nodeOutput) {
|
||||||
|
log.debug(`Node ${link.origin_id} output structure:`, {
|
||||||
|
hasData: !!nodeOutput.data,
|
||||||
|
hasShape: !!nodeOutput.shape,
|
||||||
|
dataType: typeof nodeOutput.data,
|
||||||
|
shapeType: typeof nodeOutput.shape,
|
||||||
|
keys: Object.keys(nodeOutput)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only use if it has actual tensor data
|
||||||
|
if (nodeOutput.data && nodeOutput.shape) {
|
||||||
|
maskOutput = nodeOutput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maskOutput && maskOutput.data && maskOutput.shape) {
|
||||||
|
try {
|
||||||
|
// Derive dimensions from shape or explicit width/height
|
||||||
|
let width = (maskOutput.width as number) || 0;
|
||||||
|
let height = (maskOutput.height as number) || 0;
|
||||||
|
const shape = maskOutput.shape as number[]; // e.g. [1,H,W] or [1,H,W,1]
|
||||||
|
if ((!width || !height) && Array.isArray(shape)) {
|
||||||
|
if (shape.length >= 3) {
|
||||||
|
height = shape[1];
|
||||||
|
width = shape[2];
|
||||||
|
} else if (shape.length === 2) {
|
||||||
|
height = shape[0];
|
||||||
|
width = shape[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error("Cannot determine mask dimensions from nodeOutputs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine channels count
|
||||||
|
let channels = 1;
|
||||||
|
if (Array.isArray(shape) && shape.length >= 4) {
|
||||||
|
channels = shape[3];
|
||||||
|
} else if ((maskOutput as any).channels) {
|
||||||
|
channels = (maskOutput as any).channels;
|
||||||
|
} else {
|
||||||
|
const len = (maskOutput.data as any).length;
|
||||||
|
channels = Math.max(1, Math.floor(len / (width * height)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use unified tensorToImageData for masks
|
||||||
|
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
|
||||||
|
if (!maskImageData) throw new Error("Failed to convert mask tensor to image data");
|
||||||
|
|
||||||
|
// Create canvas and put image data
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create mask context");
|
||||||
|
ctx.putImageData(maskImageData, 0, 0);
|
||||||
|
|
||||||
|
// Convert to HTMLImageElement
|
||||||
|
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
|
||||||
|
|
||||||
|
// Respect fit_on_add (scale to output area)
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w: any) => w.name === "fit_on_add") : null;
|
||||||
|
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
|
||||||
|
|
||||||
|
let finalMaskImg: HTMLImageElement = maskImg;
|
||||||
|
if (shouldFit) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
(this.canvas as any).maskAppliedFromInput = true;
|
||||||
|
this.canvas.canvasState.saveMaskState();
|
||||||
|
this.canvas.render();
|
||||||
|
// Mark this mask link as loaded to avoid re-applying
|
||||||
|
this.canvas.lastLoadedMaskLinkId = maskLinkId;
|
||||||
|
maskLoaded = true;
|
||||||
|
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
|
||||||
|
log.info(`Mask node ${(this.canvas.node as any).graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
|
||||||
|
// Don't retry - data won't be available until workflow runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check backend if we have actual inputs connected
|
||||||
|
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
|
||||||
|
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
|
||||||
|
|
||||||
|
// If mask input is disconnected, clear any currently applied mask to ensure full separation
|
||||||
|
if (!hasMaskInput) {
|
||||||
|
(this.canvas as any).maskAppliedFromInput = false;
|
||||||
|
this.canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasImageInput && !hasMaskInput) {
|
||||||
|
log.debug("No inputs connected, skipping backend check");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip backend check during mask connection if we didn't get immediate data
|
||||||
|
if (reason === "mask_connect" && !maskLoaded) {
|
||||||
|
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check backend for input data only if we have connected inputs
|
||||||
|
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.has_input) {
|
||||||
|
// Dedupe: skip only if backend payload matches last loaded batch hash
|
||||||
|
let backendBatchHash: string | undefined;
|
||||||
|
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
|
||||||
|
backendBatchHash = result.data.input_images_batch.map((i: any) => i.data).join('|');
|
||||||
|
} else if (result.data?.input_image) {
|
||||||
|
backendBatchHash = result.data.input_image;
|
||||||
|
}
|
||||||
|
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
|
||||||
|
const shouldCheckMask = hasMaskInput && allowMask;
|
||||||
|
|
||||||
|
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
|
||||||
|
log.debug("Backend input data unchanged and no mask to check, skipping reload");
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
return;
|
||||||
|
} else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
|
||||||
|
log.debug("Images unchanged but need to check mask, continuing...");
|
||||||
|
imageLoaded = true; // Mark images as already loaded to skip reloading them
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already loaded image data (by checking the current link)
|
||||||
|
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||||
|
const currentLinkId = this.canvas.node.inputs[0].link;
|
||||||
|
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
|
||||||
|
// Mark this link as loaded
|
||||||
|
this.canvas.lastLoadedLinkId = currentLinkId;
|
||||||
|
imageLoaded = false; // Will load from backend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
|
||||||
|
// Only reset if the mask link actually changed
|
||||||
|
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
|
||||||
|
const currentMaskLinkId = this.canvas.node.inputs[1].link;
|
||||||
|
// Only reset if this is a different mask link than what we loaded before
|
||||||
|
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
|
||||||
|
maskLoaded = false;
|
||||||
|
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
|
||||||
|
} else {
|
||||||
|
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
|
||||||
|
maskLoaded = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No mask input connected, or mask loading not allowed right now
|
||||||
|
maskLoaded = true; // Mark as loaded to skip mask processing
|
||||||
|
if (!allowMask) {
|
||||||
|
log.debug("Mask loading is currently disabled by caller, skipping mask check");
|
||||||
|
} else {
|
||||||
|
log.debug("No mask input connected, skipping mask check");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Input data found from backend, adding to canvas");
|
||||||
|
const inputData = result.data;
|
||||||
|
|
||||||
|
// Compute backend batch hash for dedupe and state
|
||||||
|
let backendHashNow: string | undefined;
|
||||||
|
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
|
||||||
|
backendHashNow = inputData.input_images_batch.map((i: any) => i.data).join('|');
|
||||||
|
} else if (inputData?.input_image) {
|
||||||
|
backendHashNow = inputData.input_image;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just update the hash without removing any layers
|
||||||
|
if (backendHashNow) {
|
||||||
|
log.info("New backend input data detected, adding new layers");
|
||||||
|
this.canvas.lastLoadedImageSrc = backendHashNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we've loaded input data for this execution
|
||||||
|
this.canvas.inputDataLoaded = true;
|
||||||
|
|
||||||
|
// Determine add mode based on fit_on_add setting
|
||||||
|
const widgets = this.canvas.node.widgets;
|
||||||
|
const fitOnAddWidget = widgets ? widgets.find((w: any) => w.name === "fit_on_add") : null;
|
||||||
|
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
|
||||||
|
|
||||||
|
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
|
||||||
|
if (allowImage && !imageLoaded && hasImageInput) {
|
||||||
|
if (inputData.input_images_batch) {
|
||||||
|
// Handle batch of images
|
||||||
|
const batch = inputData.input_images_batch;
|
||||||
|
log.info(`Processing batch of ${batch.length} images from backend`);
|
||||||
|
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
const imgData = batch[i];
|
||||||
|
const img = await createImageFromSource(imgData.data);
|
||||||
|
|
||||||
|
// Add image to canvas with unique name
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{ name: `Batch Image ${i + 1}` },
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`All ${batch.length} batch images added from backend`);
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
|
||||||
|
} else if (inputData.input_image) {
|
||||||
|
// Handle single image (backward compatibility)
|
||||||
|
const img = await createImageFromSource(inputData.input_image);
|
||||||
|
|
||||||
|
// Add image to canvas at output area position
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(
|
||||||
|
img,
|
||||||
|
{},
|
||||||
|
addMode,
|
||||||
|
this.canvas.outputAreaBounds
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Single input image added as new layer to canvas");
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
} else {
|
||||||
|
log.debug("No input image data from backend");
|
||||||
|
}
|
||||||
|
} else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
|
||||||
|
log.debug("Backend has image data but no image input connected, skipping image load");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
|
||||||
|
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
|
||||||
|
log.info("Processing input mask");
|
||||||
|
|
||||||
|
// Load mask image
|
||||||
|
const maskImg = await createImageFromSource(inputData.input_mask);
|
||||||
|
|
||||||
|
// Determine if we should fit the mask or use it at original size
|
||||||
|
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
|
||||||
|
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
|
||||||
|
|
||||||
|
let finalMaskImg: HTMLImageElement = maskImg;
|
||||||
|
if (shouldFit && this.canvas.maskTool) {
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to MaskTool (centers internally)
|
||||||
|
if (this.canvas.maskTool) {
|
||||||
|
this.canvas.maskTool.setMask(finalMaskImg, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
(this.canvas as any).maskAppliedFromInput = true;
|
||||||
|
// Save the mask state
|
||||||
|
this.canvas.canvasState.saveMaskState()
|
||||||
|
|
||||||
|
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
|
||||||
|
} else if (!hasMaskInput && inputData.input_mask) {
|
||||||
|
log.debug("Backend has mask data but no mask input connected, skipping mask load");
|
||||||
|
} else if (!allowMask && inputData.input_mask) {
|
||||||
|
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug("No input data from backend");
|
||||||
|
// Don't schedule another check - we'll only check when explicitly triggered
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error checking for input data:", error);
|
||||||
|
// Don't schedule another check on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleInputDataCheck(): void {
|
||||||
|
// Schedule a retry for mask data check when nodeOutputs are not ready yet
|
||||||
|
if (this.canvas.pendingInputDataCheck) {
|
||||||
|
clearTimeout(this.canvas.pendingInputDataCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
|
||||||
|
this.canvas.pendingInputDataCheck = null;
|
||||||
|
log.debug("Retrying input data check for mask...");
|
||||||
|
|
||||||
|
}, 500); // Shorter delay for mask data retry
|
||||||
|
}
|
||||||
|
|
||||||
scheduleDataCheck(): void {
|
scheduleDataCheck(): void {
|
||||||
if (this.canvas.pendingDataCheck) {
|
if (this.canvas.pendingDataCheck) {
|
||||||
clearTimeout(this.canvas.pendingDataCheck);
|
clearTimeout(this.canvas.pendingDataCheck);
|
||||||
@@ -494,59 +954,11 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convertTensorToImageData(tensor: any): ImageData | null {
|
convertTensorToImageData(tensor: any): ImageData | null {
|
||||||
try {
|
return tensorToImageData(tensor, 'rgb');
|
||||||
const shape = tensor.shape;
|
|
||||||
const height = shape[1];
|
|
||||||
const width = shape[2];
|
|
||||||
const channels = shape[3];
|
|
||||||
|
|
||||||
log.debug("Converting tensor:", {
|
|
||||||
shape: shape,
|
|
||||||
dataRange: {
|
|
||||||
min: tensor.min_val,
|
|
||||||
max: tensor.max_val
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageData = new ImageData(width, height);
|
|
||||||
const data = new Uint8ClampedArray(width * height * 4);
|
|
||||||
|
|
||||||
const flatData = tensor.data;
|
|
||||||
const pixelCount = width * height;
|
|
||||||
|
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
|
||||||
const pixelIndex = i * 4;
|
|
||||||
const tensorIndex = i * channels;
|
|
||||||
|
|
||||||
for (let c = 0; c < channels; c++) {
|
|
||||||
const value = flatData[tensorIndex + c];
|
|
||||||
|
|
||||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
|
||||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
data[pixelIndex + 3] = 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
imageData.data.set(data);
|
|
||||||
return imageData;
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Error converting tensor:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return createImageFromImageData(imageData);
|
||||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
|
||||||
if (!ctx) throw new Error("Could not create canvas context");
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async processMaskData(maskData: any): Promise<void> {
|
async processMaskData(maskData: any): Promise<void> {
|
||||||
@@ -613,12 +1025,7 @@ export class CanvasIO {
|
|||||||
const newLayers: (Layer | null)[] = [];
|
const newLayers: (Layer | null)[] = [];
|
||||||
|
|
||||||
for (const imageData of result.images) {
|
for (const imageData of result.images) {
|
||||||
const img = new Image();
|
const img = await createImageFromSource(imageData);
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
img.onload = resolve;
|
|
||||||
img.onerror = reject;
|
|
||||||
img.src = imageData;
|
|
||||||
});
|
|
||||||
|
|
||||||
let processedImage = img;
|
let processedImage = img;
|
||||||
|
|
||||||
@@ -647,37 +1054,31 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
if (!ctx) {
|
||||||
if (!ctx) {
|
throw new Error("Could not create canvas context for clipping");
|
||||||
reject(new Error("Could not create canvas context for clipping"));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the image first
|
// Draw the image first
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
// Calculate custom shape position accounting for extensions
|
// Calculate custom shape position accounting for extensions
|
||||||
// Custom shape should maintain its relative position within the original canvas area
|
// Custom shape should maintain its relative position within the original canvas area
|
||||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||||
|
|
||||||
// Create a clipping mask using the shape with extension offset
|
// Create a clipping mask using the shape with extension offset
|
||||||
ctx.globalCompositeOperation = 'destination-in';
|
ctx.globalCompositeOperation = 'destination-in';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||||
for (let i = 1; i < shape.points.length; i++) {
|
for (let i = 1; i < shape.points.length; i++) {
|
||||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||||
}
|
}
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Create a new image from the clipped canvas
|
// Create a new image from the clipped canvas
|
||||||
const clippedImage = new Image();
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
clippedImage.onload = () => resolve(clippedImage);
|
|
||||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
|
||||||
clippedImage.src = canvas.toDataURL();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1013
src/CanvasLayers.ts
1013
src/CanvasLayers.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { createCanvas } from "./utils/CommonUtils.js";
|
import { createCanvas } from "./utils/CommonUtils.js";
|
||||||
|
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||||
import type { Canvas } from './Canvas';
|
import type { Canvas } from './Canvas';
|
||||||
import type { Layer } from './types';
|
import type { Layer } from './types';
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ export class CanvasLayersPanel {
|
|||||||
// Preload icons
|
// Preload icons
|
||||||
this.initializeIcons();
|
this.initializeIcons();
|
||||||
|
|
||||||
|
// Load CSS for layers panel
|
||||||
|
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||||
|
|
||||||
log.info('CanvasLayersPanel initialized');
|
log.info('CanvasLayersPanel initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,23 +51,16 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container';
|
||||||
width: ${size}px;
|
iconContainer.style.width = `${size}px`;
|
||||||
height: ${size}px;
|
iconContainer.style.height = `${size}px`;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = iconLoader.getIcon(toolName);
|
const icon = iconLoader.getIcon(toolName);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode() as HTMLImageElement;
|
const img = icon.cloneNode() as HTMLImageElement;
|
||||||
img.style.cssText = `
|
img.style.width = `${size}px`;
|
||||||
width: ${size}px;
|
img.style.height = `${size}px`;
|
||||||
height: ${size}px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
} else if (icon instanceof HTMLCanvasElement) {
|
} else if (icon instanceof HTMLCanvasElement) {
|
||||||
const { canvas, ctx } = createCanvas(size, size);
|
const { canvas, ctx } = createCanvas(size, size);
|
||||||
@@ -74,9 +71,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback text
|
// Fallback text
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||||
iconContainer.style.color = '#ffffff';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
@@ -88,25 +85,16 @@ export class CanvasLayersPanel {
|
|||||||
} else {
|
} else {
|
||||||
// Create a "hidden" version of the visibility icon
|
// Create a "hidden" version of the visibility icon
|
||||||
const iconContainer = document.createElement('div');
|
const iconContainer = document.createElement('div');
|
||||||
iconContainer.style.cssText = `
|
iconContainer.className = 'icon-container visibility-hidden';
|
||||||
width: 16px;
|
iconContainer.style.width = '16px';
|
||||||
height: 16px;
|
iconContainer.style.height = '16px';
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
if (icon instanceof HTMLImageElement) {
|
if (icon instanceof HTMLImageElement) {
|
||||||
const img = icon.cloneNode() as HTMLImageElement;
|
const img = icon.cloneNode() as HTMLImageElement;
|
||||||
img.style.cssText = `
|
img.style.width = '16px';
|
||||||
width: 16px;
|
img.style.height = '16px';
|
||||||
height: 16px;
|
|
||||||
filter: brightness(0) invert(1);
|
|
||||||
opacity: 0.3;
|
|
||||||
`;
|
|
||||||
iconContainer.appendChild(img);
|
iconContainer.appendChild(img);
|
||||||
} else if (icon instanceof HTMLCanvasElement) {
|
} else if (icon instanceof HTMLCanvasElement) {
|
||||||
const { canvas, ctx } = createCanvas(16, 16);
|
const { canvas, ctx } = createCanvas(16, 16);
|
||||||
@@ -118,9 +106,9 @@ export class CanvasLayersPanel {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
|
iconContainer.classList.add('fallback-text');
|
||||||
iconContainer.textContent = 'H';
|
iconContainer.textContent = 'H';
|
||||||
iconContainer.style.fontSize = '10px';
|
iconContainer.style.fontSize = '10px';
|
||||||
iconContainer.style.color = '#888888';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return iconContainer;
|
return iconContainer;
|
||||||
@@ -133,6 +121,7 @@ export class CanvasLayersPanel {
|
|||||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="layers-panel-header">
|
<div class="layers-panel-header">
|
||||||
|
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||||
<span class="layers-panel-title">Layers</span>
|
<span class="layers-panel-title">Layers</span>
|
||||||
<div class="layers-panel-controls">
|
<div class="layers-panel-controls">
|
||||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||||
@@ -145,10 +134,9 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||||
|
|
||||||
this.injectStyles();
|
|
||||||
|
|
||||||
// Setup event listeners dla przycisków
|
// Setup event listeners dla przycisków
|
||||||
this.setupControlButtons();
|
this.setupControlButtons();
|
||||||
|
this.setupMasterVisibilityToggle();
|
||||||
|
|
||||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
@@ -156,6 +144,26 @@ export class CanvasLayersPanel {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Ctrl+C/V for layer copy/paste when panel has focus
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key.toLowerCase() === 'c') {
|
||||||
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
|
log.info('Layers copied from panel');
|
||||||
|
}
|
||||||
|
} else if (e.key.toLowerCase() === 'v') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
log.info('Layers pasted from panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,218 +171,10 @@ export class CanvasLayersPanel {
|
|||||||
return this.container;
|
return this.container;
|
||||||
}
|
}
|
||||||
|
|
||||||
injectStyles(): void {
|
|
||||||
const styleId = 'layers-panel-styles';
|
|
||||||
if (document.getElementById(styleId)) {
|
|
||||||
return; // Style już istnieją
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = styleId;
|
|
||||||
style.textContent = `
|
|
||||||
.layers-panel {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: 1px solid #3a3a3a;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ffffff;
|
|
||||||
user-select: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid #3a3a3a;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-title {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-panel-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn {
|
|
||||||
background: #3a3a3a;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:hover {
|
|
||||||
background: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-btn:active {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 4px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.selected {
|
|
||||||
background: #2d5aa0 !important;
|
|
||||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-row.dragging {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.layer-thumbnail {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border: 1px solid #4a4a4a;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: transparent;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
|
||||||
background-size: 8px 8px;
|
|
||||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-thumbnail canvas {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name.editing {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border: 1px solid #6a6a6a;
|
|
||||||
outline: none;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-name input {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 12px;
|
|
||||||
width: 100%;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-insertion-line {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: #4a7bc8;
|
|
||||||
border-radius: 1px;
|
|
||||||
z-index: 1000;
|
|
||||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-track {
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #4a4a4a;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #5a5a5a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-visibility-toggle:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.head.appendChild(style);
|
|
||||||
log.debug('Styles injected');
|
|
||||||
}
|
|
||||||
|
|
||||||
setupControlButtons(): void {
|
setupControlButtons(): void {
|
||||||
if (!this.container) return;
|
if (!this.container) return;
|
||||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||||
|
|
||||||
// Add delete icon to button
|
// Add delete icon to button
|
||||||
if (deleteBtn) {
|
if (deleteBtn) {
|
||||||
@@ -386,8 +186,79 @@ export class CanvasLayersPanel {
|
|||||||
log.info('Delete layer button clicked');
|
log.info('Delete layer button clicked');
|
||||||
this.deleteSelectedLayers();
|
this.deleteSelectedLayers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial button state update
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupMasterVisibilityToggle(): void {
|
||||||
|
if (!this.container) return;
|
||||||
|
const toggleContainer = this.container.querySelector('.master-visibility-toggle') as HTMLElement;
|
||||||
|
if (!toggleContainer) return;
|
||||||
|
|
||||||
|
const updateToggleState = () => {
|
||||||
|
const total = this.canvas.layers.length;
|
||||||
|
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
|
||||||
|
toggleContainer.innerHTML = '';
|
||||||
|
|
||||||
|
const checkboxContainer = document.createElement('div');
|
||||||
|
checkboxContainer.className = 'checkbox-container';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.id = 'master-visibility-checkbox';
|
||||||
|
|
||||||
|
const customCheckbox = document.createElement('span');
|
||||||
|
customCheckbox.className = 'custom-checkbox';
|
||||||
|
|
||||||
|
checkboxContainer.appendChild(checkbox);
|
||||||
|
checkboxContainer.appendChild(customCheckbox);
|
||||||
|
|
||||||
|
if (visibleCount === 0) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
checkbox.indeterminate = false;
|
||||||
|
customCheckbox.classList.remove('checked', 'indeterminate');
|
||||||
|
} else if (visibleCount === total) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
checkbox.indeterminate = false;
|
||||||
|
customCheckbox.classList.add('checked');
|
||||||
|
customCheckbox.classList.remove('indeterminate');
|
||||||
|
} else {
|
||||||
|
checkbox.checked = false;
|
||||||
|
checkbox.indeterminate = true;
|
||||||
|
customCheckbox.classList.add('indeterminate');
|
||||||
|
customCheckbox.classList.remove('checked');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkboxContainer.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let newVisible: boolean;
|
||||||
|
if (checkbox.indeterminate) {
|
||||||
|
newVisible = false; // hide all when mixed
|
||||||
|
} else if (checkbox.checked) {
|
||||||
|
newVisible = false; // toggle to hide all
|
||||||
|
} else {
|
||||||
|
newVisible = true; // toggle to show all
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.layers.forEach(layer => {
|
||||||
|
layer.visible = newVisible;
|
||||||
|
});
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.requestSaveState();
|
||||||
|
updateToggleState();
|
||||||
|
this.renderLayers();
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleContainer.appendChild(checkboxContainer);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateToggleState();
|
||||||
|
this._updateMasterVisibilityToggle = updateToggleState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateMasterVisibilityToggle?: () => void;
|
||||||
|
|
||||||
renderLayers(): void {
|
renderLayers(): void {
|
||||||
if (!this.layersContainer) {
|
if (!this.layersContainer) {
|
||||||
log.warn('Layers container not initialized');
|
log.warn('Layers container not initialized');
|
||||||
@@ -405,10 +276,11 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
sortedLayers.forEach((layer: Layer, index: number) => {
|
||||||
const layerElement = this.createLayerElement(layer, index);
|
const layerElement = this.createLayerElement(layer, index);
|
||||||
if(this.layersContainer)
|
if (this.layersContainer)
|
||||||
this.layersContainer.appendChild(layerElement);
|
this.layersContainer.appendChild(layerElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this._updateMasterVisibilityToggle) this._updateMasterVisibilityToggle();
|
||||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +289,7 @@ export class CanvasLayersPanel {
|
|||||||
layerRow.className = 'layer-row';
|
layerRow.className = 'layer-row';
|
||||||
layerRow.draggable = true;
|
layerRow.draggable = true;
|
||||||
layerRow.dataset.layerIndex = String(index);
|
layerRow.dataset.layerIndex = String(index);
|
||||||
|
|
||||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
layerRow.classList.add('selected');
|
layerRow.classList.add('selected');
|
||||||
@@ -466,7 +338,7 @@ export class CanvasLayersPanel {
|
|||||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||||
const scaledWidth = layer.image.width * scale;
|
const scaledWidth = layer.image.width * scale;
|
||||||
const scaledHeight = layer.image.height * scale;
|
const scaledHeight = layer.image.height * scale;
|
||||||
|
|
||||||
// Wycentruj obraz
|
// Wycentruj obraz
|
||||||
const x = (48 - scaledWidth) / 2;
|
const x = (48 - scaledWidth) / 2;
|
||||||
const y = (48 - scaledHeight) / 2;
|
const y = (48 - scaledHeight) / 2;
|
||||||
@@ -495,6 +367,7 @@ export class CanvasLayersPanel {
|
|||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||||
this.canvas.updateSelection(newSelection);
|
this.canvas.updateSelection(newSelection);
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,9 +403,13 @@ export class CanvasLayersPanel {
|
|||||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
|
|
||||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
|
|
||||||
|
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
|
||||||
|
this.canvas.canvas.focus();
|
||||||
|
|
||||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||||
}
|
}
|
||||||
@@ -540,15 +417,15 @@ export class CanvasLayersPanel {
|
|||||||
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
|
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
|
||||||
const currentName = layer.name;
|
const currentName = layer.name;
|
||||||
nameElement.classList.add('editing');
|
nameElement.classList.add('editing');
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.value = currentName;
|
input.value = currentName;
|
||||||
input.style.width = '100%';
|
input.style.width = '100%';
|
||||||
|
|
||||||
nameElement.innerHTML = '';
|
nameElement.innerHTML = '';
|
||||||
nameElement.appendChild(input);
|
nameElement.appendChild(input);
|
||||||
|
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
|
|
||||||
@@ -558,7 +435,7 @@ export class CanvasLayersPanel {
|
|||||||
layer.name = newName;
|
layer.name = newName;
|
||||||
nameElement.classList.remove('editing');
|
nameElement.classList.remove('editing');
|
||||||
nameElement.textContent = newName;
|
nameElement.textContent = newName;
|
||||||
|
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
log.info(`Layer renamed to: ${newName}`);
|
log.info(`Layer renamed to: ${newName}`);
|
||||||
};
|
};
|
||||||
@@ -582,11 +459,11 @@ export class CanvasLayersPanel {
|
|||||||
if (!existingNames.includes(proposedName)) {
|
if (!existingNames.includes(proposedName)) {
|
||||||
return proposedName;
|
return proposedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||||
let baseName, startNumber;
|
let baseName, startNumber;
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
baseName = match[1].trim();
|
baseName = match[1].trim();
|
||||||
startNumber = parseInt(match[2]) + 1;
|
startNumber = parseInt(match[2]) + 1;
|
||||||
@@ -594,34 +471,34 @@ export class CanvasLayersPanel {
|
|||||||
baseName = proposedName;
|
baseName = proposedName;
|
||||||
startNumber = 1;
|
startNumber = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Znajdź pierwszą dostępną numerację
|
// Znajdź pierwszą dostępną numerację
|
||||||
let counter = startNumber;
|
let counter = startNumber;
|
||||||
let uniqueName;
|
let uniqueName;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
uniqueName = `${baseName} (${counter})`;
|
uniqueName = `${baseName} (${counter})`;
|
||||||
counter++;
|
counter++;
|
||||||
} while (existingNames.includes(uniqueName));
|
} while (existingNames.includes(uniqueName));
|
||||||
|
|
||||||
return uniqueName;
|
return uniqueName;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleLayerVisibility(layer: Layer): void {
|
toggleLayerVisibility(layer: Layer): void {
|
||||||
layer.visible = !layer.visible;
|
layer.visible = !layer.visible;
|
||||||
|
|
||||||
// If layer became invisible and is selected, deselect it
|
// If layer became invisible and is selected, deselect it
|
||||||
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||||
this.canvas.updateSelection(newSelection);
|
this.canvas.updateSelection(newSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
|
|
||||||
// Update the eye icon in the panel
|
// Update the eye icon in the panel
|
||||||
this.renderLayers();
|
this.renderLayers();
|
||||||
|
|
||||||
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
|
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +558,7 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
const line = document.createElement('div');
|
const line = document.createElement('div');
|
||||||
line.className = 'drag-insertion-line';
|
line.className = 'drag-insertion-line';
|
||||||
|
|
||||||
if (isUpperHalf) {
|
if (isUpperHalf) {
|
||||||
line.style.top = '-1px';
|
line.style.top = '-1px';
|
||||||
} else {
|
} else {
|
||||||
@@ -709,7 +586,7 @@ export class CanvasLayersPanel {
|
|||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const midpoint = rect.top + rect.height / 2;
|
const midpoint = rect.top + rect.height / 2;
|
||||||
const isUpperHalf = e.clientY < midpoint;
|
const isUpperHalf = e.clientY < midpoint;
|
||||||
|
|
||||||
// Oblicz docelowy indeks
|
// Oblicz docelowy indeks
|
||||||
let insertIndex = targetIndex;
|
let insertIndex = targetIndex;
|
||||||
if (!isUpperHalf) {
|
if (!isUpperHalf) {
|
||||||
@@ -718,7 +595,7 @@ export class CanvasLayersPanel {
|
|||||||
|
|
||||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||||
|
|
||||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,12 +628,32 @@ export class CanvasLayersPanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||||
|
*/
|
||||||
|
updateButtonStates(): void {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||||
|
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = !hasSelectedLayers;
|
||||||
|
deleteBtn.title = hasSelectedLayers
|
||||||
|
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||||
|
: 'No layers selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||||
*/
|
*/
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
this.updateSelectionAppearance();
|
this.updateSelectionAppearance();
|
||||||
|
this.updateButtonStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
@@ -767,7 +664,7 @@ export class CanvasLayersPanel {
|
|||||||
this.layersContainer = null;
|
this.layersContainer = null;
|
||||||
this.draggedElements = [];
|
this.draggedElements = [];
|
||||||
this.removeDragInsertionLine();
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
log.info('CanvasLayersPanel destroyed');
|
log.info('CanvasLayersPanel destroyed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ export class CanvasRenderer {
|
|||||||
lastRenderTime: any;
|
lastRenderTime: any;
|
||||||
renderAnimationFrame: any;
|
renderAnimationFrame: any;
|
||||||
renderInterval: any;
|
renderInterval: any;
|
||||||
|
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||||
|
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||||
|
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||||
constructor(canvas: any) {
|
constructor(canvas: any) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.renderAnimationFrame = null;
|
this.renderAnimationFrame = null;
|
||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,9 +148,11 @@ export class CanvasRenderer {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
} else {
|
} else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -179,6 +188,11 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw grab icons for selected layers when hovering
|
||||||
|
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
|
||||||
|
this.drawGrabIcons(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
this.drawCanvasOutline(ctx);
|
this.drawCanvasOutline(ctx);
|
||||||
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||||
@@ -186,6 +200,7 @@ export class CanvasRenderer {
|
|||||||
this.renderInteractionElements(ctx);
|
this.renderInteractionElements(ctx);
|
||||||
this.canvas.shapeTool.render(ctx);
|
this.canvas.shapeTool.render(ctx);
|
||||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||||
|
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||||
this.renderLayerInfo(ctx);
|
this.renderLayerInfo(ctx);
|
||||||
|
|
||||||
// Update custom shape menu position and visibility
|
// Update custom shape menu position and visibility
|
||||||
@@ -205,6 +220,12 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Ensure overlay canvases are in DOM and properly sized
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
this.updateOverlaySize();
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
// Update Batch Preview UI positions
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||||
@@ -532,46 +553,89 @@ export class CanvasRenderer {
|
|||||||
drawSelectionFrame(ctx: any, layer: any) {
|
drawSelectionFrame(ctx: any, layer: any) {
|
||||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||||
ctx.strokeStyle = '#00ff00';
|
|
||||||
ctx.lineWidth = lineWidth;
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
|
// --- CROP MODE ---
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
|
||||||
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
|
ctx.strokeStyle = '#007bff';
|
||||||
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 2. Draw solid blue line for the crop bounds
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
const s = layer.cropBounds;
|
||||||
|
|
||||||
|
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
|
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
|
const cropRectW = s.width * layerScaleX;
|
||||||
|
const cropRectH = s.height * layerScaleY;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// --- TRANSFORM MODE ---
|
||||||
|
ctx.strokeStyle = '#00ff00'; // Green
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
const halfW = layer.width / 2;
|
||||||
|
const halfH = layer.height / 2;
|
||||||
|
|
||||||
|
// Draw adaptive solid green line for transform frame
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||||
|
|
||||||
|
// Draw line to rotation handle
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.beginPath();
|
||||||
|
const startY = layer.flipV ? halfH : -halfH;
|
||||||
|
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||||
|
ctx.moveTo(0, startY);
|
||||||
|
ctx.lineTo(0, endY);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
// --- DRAW HANDLES (Unified Logic) ---
|
||||||
const halfW = layer.width / 2;
|
|
||||||
const halfH = layer.height / 2;
|
|
||||||
|
|
||||||
// Górna krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
|
||||||
// Prawa krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
|
||||||
// Dolna krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
|
||||||
// Lewa krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
|
||||||
|
|
||||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Rysuj uchwyty
|
|
||||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
|
// Skip rotation handle in crop mode
|
||||||
|
if (layer.cropMode && key === 'rot') continue;
|
||||||
|
|
||||||
const point = handles[key];
|
const point = handles[key];
|
||||||
ctx.beginPath();
|
// The handle position is already in world space.
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
// We need to convert it to the layer's local, un-rotated space.
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const dx = point.x - centerX;
|
||||||
|
const dy = point.y - centerY;
|
||||||
|
|
||||||
|
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||||
const rad = -layer.rotation * Math.PI / 180;
|
const rad = -layer.rotation * Math.PI / 180;
|
||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const cos = Math.cos(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const sin = Math.sin(rad);
|
||||||
|
const localX = dx * cos - dy * sin;
|
||||||
|
const localY = dx * sin + dy * cos;
|
||||||
|
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
// The context is already flipped. We need to flip the coordinates
|
||||||
|
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||||
|
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||||
|
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
@@ -667,4 +731,392 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
padding: 8
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||||
|
*/
|
||||||
|
initOverlay(): void {
|
||||||
|
// Setup overlay canvas to match main canvas
|
||||||
|
this.updateOverlaySize();
|
||||||
|
|
||||||
|
// Position overlay canvas on top of main canvas
|
||||||
|
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||||
|
this.canvas.overlayCanvas.style.left = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.top = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||||
|
|
||||||
|
// Add overlay to DOM when main canvas is added
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
|
||||||
|
log.debug('Overlay canvas initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add overlay canvas to DOM if main canvas has a parent
|
||||||
|
*/
|
||||||
|
addOverlayToDOM(): void {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||||
|
log.debug('Overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update overlay canvas size to match main canvas
|
||||||
|
*/
|
||||||
|
updateOverlaySize(): void {
|
||||||
|
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||||
|
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||||
|
|
||||||
|
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
|
||||||
|
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear overlay canvas
|
||||||
|
*/
|
||||||
|
clearOverlay(): void {
|
||||||
|
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||||
|
*/
|
||||||
|
initStrokeOverlay(): void {
|
||||||
|
// Create canvas if not created yet
|
||||||
|
if (!this.strokeOverlayCanvas) {
|
||||||
|
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||||
|
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size match main canvas
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
|
// Position above main canvas but below cursor overlay
|
||||||
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
|
this.strokeOverlayCanvas.style.left = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||||
|
|
||||||
|
// Add to DOM
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
log.debug('Stroke overlay canvas initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add stroke overlay canvas to DOM if needed
|
||||||
|
*/
|
||||||
|
addStrokeOverlayToDOM(): void {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||||
|
log.debug('Stroke overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure stroke overlay size matches main canvas
|
||||||
|
*/
|
||||||
|
updateStrokeOverlaySize(): void {
|
||||||
|
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||||
|
this.strokeOverlayCanvas.width = w;
|
||||||
|
this.strokeOverlayCanvas.height = h;
|
||||||
|
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stroke overlay
|
||||||
|
*/
|
||||||
|
clearMaskStrokeOverlay(): void {
|
||||||
|
if (!this.strokeOverlayCtx) return;
|
||||||
|
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||||
|
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||||
|
*/
|
||||||
|
drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void {
|
||||||
|
// Ensure overlay is present and sized
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
|
const zoom = this.canvas.viewport.zoom;
|
||||||
|
const toScreen = (p: { x: number; y: number }) => ({
|
||||||
|
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||||
|
y: (p.y - this.canvas.viewport.y) * zoom
|
||||||
|
});
|
||||||
|
|
||||||
|
const startScreen = toScreen(startWorld);
|
||||||
|
const endScreen = toScreen(endWorld);
|
||||||
|
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||||
|
const hardness = this.canvas.maskTool.brushHardness;
|
||||||
|
const strength = this.canvas.maskTool.brushStrength;
|
||||||
|
|
||||||
|
// If strength is 0, don't draw anything
|
||||||
|
if (strength <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strokeOverlayCtx.save();
|
||||||
|
|
||||||
|
// Draw line segment exactly as MaskTool does
|
||||||
|
this.strokeOverlayCtx.beginPath();
|
||||||
|
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||||
|
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||||
|
|
||||||
|
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||||
|
if (hardness === 1) {
|
||||||
|
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||||
|
} else {
|
||||||
|
const innerRadius = brushRadius * hardness;
|
||||||
|
const gradient = this.strokeOverlayCtx.createRadialGradient(
|
||||||
|
endScreen.x, endScreen.y, innerRadius,
|
||||||
|
endScreen.x, endScreen.y, brushRadius
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match line properties from MaskTool
|
||||||
|
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||||
|
this.strokeOverlayCtx.lineCap = 'round';
|
||||||
|
this.strokeOverlayCtx.lineJoin = 'round';
|
||||||
|
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.strokeOverlayCtx.stroke();
|
||||||
|
|
||||||
|
this.strokeOverlayCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the entire stroke overlay from world coordinates
|
||||||
|
* Used when viewport changes during drawing to maintain visual consistency
|
||||||
|
*/
|
||||||
|
redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void {
|
||||||
|
if (strokePoints.length < 2) return;
|
||||||
|
|
||||||
|
// Clear the overlay first
|
||||||
|
this.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
|
// Redraw all segments with current viewport
|
||||||
|
for (let i = 1; i < strokePoints.length; i++) {
|
||||||
|
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||||
|
* @param worldPoint World coordinates of cursor
|
||||||
|
*/
|
||||||
|
drawMaskBrushCursor(worldPoint: { x: number, y: number }): void {
|
||||||
|
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||||
|
this.clearOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update overlay size if needed
|
||||||
|
this.updateOverlaySize();
|
||||||
|
|
||||||
|
// Clear previous cursor
|
||||||
|
this.clearOverlay();
|
||||||
|
|
||||||
|
// Convert world coordinates to screen coordinates
|
||||||
|
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
|
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
// Get brush properties
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||||
|
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||||
|
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||||
|
|
||||||
|
// Save context state
|
||||||
|
this.canvas.overlayCtx.save();
|
||||||
|
|
||||||
|
// If strength is 0, just draw outline
|
||||||
|
if (brushStrength > 0) {
|
||||||
|
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||||
|
const gradient = this.canvas.overlayCtx.createRadialGradient(
|
||||||
|
screenX, screenY, 0,
|
||||||
|
screenX, screenY, brushRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preview alpha - subtle to not obscure content
|
||||||
|
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||||
|
|
||||||
|
if (brushHardness === 1) {
|
||||||
|
// Hard brush - uniform fill within radius
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
} else {
|
||||||
|
// Soft brush - gradient fade matching actual brush
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
if (brushHardness > 0) {
|
||||||
|
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = gradient;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw outer circle (SIZE indicator)
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
|
||||||
|
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||||
|
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||||
|
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||||
|
|
||||||
|
// Visual feedback for hardness
|
||||||
|
if (brushHardness > 0.8) {
|
||||||
|
// Hard brush - solid line
|
||||||
|
this.canvas.overlayCtx.setLineDash([]);
|
||||||
|
} else {
|
||||||
|
// Soft brush - dashed line
|
||||||
|
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||||
|
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.overlayCtx.stroke();
|
||||||
|
|
||||||
|
// Center dot for small brushes
|
||||||
|
if (brushRadius < 5) {
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore context state
|
||||||
|
this.canvas.overlayCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update overlay position when viewport changes
|
||||||
|
*/
|
||||||
|
updateOverlayPosition(): void {
|
||||||
|
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||||
|
// Just ensure it's the right size
|
||||||
|
this.updateOverlaySize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw grab icons in the center of selected layers
|
||||||
|
*/
|
||||||
|
drawGrabIcons(ctx: any): void {
|
||||||
|
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0) return;
|
||||||
|
|
||||||
|
const iconRadius = 20 / this.canvas.viewport.zoom;
|
||||||
|
const innerRadius = 12 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
selectedLayers.forEach((layer: any) => {
|
||||||
|
if (!layer.visible) return;
|
||||||
|
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// Draw outer circle (background)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
|
||||||
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw hand/grab icon (simplified)
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
// Draw four dots representing grab points
|
||||||
|
const dotRadius = 2 / this.canvas.viewport.zoom;
|
||||||
|
const dotDistance = 6 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
// Top-left
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Top-right
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bottom-left
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bottom-right
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw transform handles for output area when in transform mode
|
||||||
|
*/
|
||||||
|
renderOutputAreaTransformHandles(ctx: any): void {
|
||||||
|
if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = this.canvas.outputAreaBounds;
|
||||||
|
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
// Define handle positions
|
||||||
|
const handles = {
|
||||||
|
'nw': { x: bounds.x, y: bounds.y },
|
||||||
|
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||||
|
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||||
|
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||||
|
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||||
|
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||||
|
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||||
|
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw handles
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#000000';
|
||||||
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
for (const [name, pos] of Object.entries(handles)) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a highlight around the output area
|
||||||
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
||||||
|
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ export class CanvasState {
|
|||||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||||
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
|
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
|
||||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
|
||||||
|
|
||||||
if (this.canvas.layers.length === 0) {
|
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||||
log.warn("No valid layers loaded, state may be corrupted.");
|
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
|
||||||
return false;
|
// Don't return false - allow empty canvas to be valid
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canvas.updateSelectionAfterHistory();
|
this.canvas.updateSelectionAfterHistory();
|
||||||
@@ -235,6 +235,7 @@ export class CanvasState {
|
|||||||
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
||||||
if (typeof imageSrc === 'string') {
|
if (typeof imageSrc === 'string') {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||||
const newLayer: Layer = {...layerData, image: img};
|
const newLayer: Layer = {...layerData, image: img};
|
||||||
@@ -250,6 +251,7 @@ export class CanvasState {
|
|||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.drawImage(imageSrc, 0, 0);
|
ctx.drawImage(imageSrc, 0, 0);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||||
const newLayer: Layer = {...layerData, image: img};
|
const newLayer: Layer = {...layerData, image: img};
|
||||||
@@ -456,12 +458,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
|
|
||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,12 +477,13 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
if (nextState) {
|
if (nextState) {
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
|
||||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||||
if (maskCtx) {
|
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||||
}
|
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
|
|||||||
@@ -33,6 +33,40 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
});
|
});
|
||||||
const imageCache = new ImageCache();
|
const imageCache = new ImageCache();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to update the icon of a switch component.
|
||||||
|
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||||
|
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||||
|
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||||
|
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||||
|
* @param fallbackTrue The text fallback for the 'true' state.
|
||||||
|
* @param fallbackFalse The text fallback for the 'false' state.
|
||||||
|
*/
|
||||||
|
const updateSwitchIcon = (
|
||||||
|
knobIconEl: HTMLElement,
|
||||||
|
isChecked: boolean,
|
||||||
|
iconToolTrue: string,
|
||||||
|
iconToolFalse: string,
|
||||||
|
fallbackTrue: string,
|
||||||
|
fallbackFalse: string
|
||||||
|
) => {
|
||||||
|
if (!knobIconEl) return;
|
||||||
|
|
||||||
|
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||||
|
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||||
|
const icon = iconLoader.getIcon(iconTool);
|
||||||
|
|
||||||
|
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||||
|
if (icon instanceof HTMLImageElement) {
|
||||||
|
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
||||||
|
clonedIcon.style.width = '20px';
|
||||||
|
clonedIcon.style.height = '20px';
|
||||||
|
knobIconEl.appendChild(clonedIcon);
|
||||||
|
} else {
|
||||||
|
knobIconEl.textContent = fallbackText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const helpTooltip = $el("div.painter-tooltip", {
|
const helpTooltip = $el("div.painter-tooltip", {
|
||||||
id: `painter-help-tooltip-${node.id}`,
|
id: `painter-help-tooltip-${node.id}`,
|
||||||
}) as HTMLDivElement;
|
}) as HTMLDivElement;
|
||||||
@@ -97,7 +131,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
}),
|
}),
|
||||||
$el("button.painter-button.icon-button", {
|
$el("button.painter-button.icon-button", {
|
||||||
textContent: "?",
|
textContent: "?",
|
||||||
title: "Show shortcuts",
|
|
||||||
onmouseenter: (e: MouseEvent) => {
|
onmouseenter: (e: MouseEvent) => {
|
||||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||||
showTooltip(e.target as HTMLElement, content);
|
showTooltip(e.target as HTMLElement, content);
|
||||||
@@ -176,37 +209,56 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Helper function to get current tooltip content based on switch state
|
||||||
|
const getCurrentTooltipContent = () => {
|
||||||
|
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
||||||
|
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to update tooltip content if it's currently visible
|
||||||
|
const updateTooltipIfVisible = () => {
|
||||||
|
// Only update if tooltip is currently visible
|
||||||
|
if (helpTooltip.style.display === 'block') {
|
||||||
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
|
showTooltip(switchEl, tooltipContent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Tooltip logic
|
// Tooltip logic
|
||||||
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
||||||
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
const tooltipContent = getCurrentTooltipContent();
|
||||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
|
||||||
showTooltip(switchEl, tooltipContent);
|
showTooltip(switchEl, tooltipContent);
|
||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
|
|
||||||
// Dynamic icon and text update on toggle
|
// Dynamic icon update on toggle
|
||||||
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
||||||
|
|
||||||
const updateSwitchView = (isClipspace: boolean) => {
|
input.addEventListener('change', () => {
|
||||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
updateSwitchIcon(
|
||||||
const icon = iconLoader.getIcon(iconTool);
|
knobIcon,
|
||||||
if (icon instanceof HTMLImageElement) {
|
input.checked,
|
||||||
knobIcon.innerHTML = '';
|
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||||
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||||
clonedIcon.style.width = '20px';
|
"🗂️",
|
||||||
clonedIcon.style.height = '20px';
|
"📋"
|
||||||
knobIcon.appendChild(clonedIcon);
|
);
|
||||||
} else {
|
|
||||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
// Update tooltip content immediately after state change
|
||||||
}
|
updateTooltipIfVisible();
|
||||||
};
|
});
|
||||||
|
|
||||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
iconLoader.preloadToolIcons().then(() => {
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
updateSwitchView(isClipspace);
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
isClipspace,
|
||||||
|
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||||
|
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||||
|
"🗂️",
|
||||||
|
"📋"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return switchEl;
|
return switchEl;
|
||||||
@@ -216,90 +268,32 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
$el("button.painter-button.requires-selection", {
|
||||||
|
textContent: "Auto Adjust Output",
|
||||||
|
title: "Automatically adjust output area to fit selected layers",
|
||||||
|
onclick: () => {
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0) {
|
||||||
|
showWarningNotification("Please select one or more layers first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
|
||||||
|
if (success) {
|
||||||
|
const bounds = canvas.outputAreaBounds;
|
||||||
|
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
|
||||||
|
} else {
|
||||||
|
showErrorNotification("Cannot calculate valid output area dimensions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
$el("button.painter-button", {
|
$el("button.painter-button", {
|
||||||
textContent: "Output Area Size",
|
textContent: "Output Area Size",
|
||||||
title: "Set the size of the output area",
|
title: "Transform output area - drag handles to resize",
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
const dialog = $el("div.painter-dialog", {
|
// Activate output area transform mode
|
||||||
style: {
|
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||||
position: 'fixed',
|
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
zIndex: '9999'
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
color: "white",
|
|
||||||
marginBottom: "10px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("label", {
|
|
||||||
style: {
|
|
||||||
marginRight: "5px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("span", {}, ["Width: "])
|
|
||||||
]),
|
|
||||||
$el("input", {
|
|
||||||
type: "number",
|
|
||||||
id: "canvas-width",
|
|
||||||
value: String(canvas.width),
|
|
||||||
min: "1",
|
|
||||||
max: "4096"
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
color: "white",
|
|
||||||
marginBottom: "10px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("label", {
|
|
||||||
style: {
|
|
||||||
marginRight: "5px"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("span", {}, ["Height: "])
|
|
||||||
]),
|
|
||||||
$el("input", {
|
|
||||||
type: "number",
|
|
||||||
id: "canvas-height",
|
|
||||||
value: String(canvas.height),
|
|
||||||
min: "1",
|
|
||||||
max: "4096"
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
$el("div", {
|
|
||||||
style: {
|
|
||||||
textAlign: "right"
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
$el("button", {
|
|
||||||
id: "cancel-size",
|
|
||||||
textContent: "Cancel"
|
|
||||||
}),
|
|
||||||
$el("button", {
|
|
||||||
id: "confirm-size",
|
|
||||||
textContent: "OK"
|
|
||||||
})
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
|
|
||||||
(document.getElementById('confirm-size') as HTMLButtonElement).onclick = () => {
|
|
||||||
const widthInput = document.getElementById('canvas-width') as HTMLInputElement;
|
|
||||||
const heightInput = document.getElementById('canvas-height') as HTMLInputElement;
|
|
||||||
const width = parseInt(widthInput.value) || canvas.width;
|
|
||||||
const height = parseInt(heightInput.value) || canvas.height;
|
|
||||||
canvas.setOutputAreaSize(width, height);
|
|
||||||
document.body.removeChild(dialog);
|
|
||||||
};
|
|
||||||
|
|
||||||
(document.getElementById('cancel-size') as HTMLButtonElement).onclick = () => {
|
|
||||||
document.body.removeChild(dialog);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
@@ -326,6 +320,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
(() => {
|
||||||
|
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||||
|
id: `crop-transform-switch-${node.id}`,
|
||||||
|
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||||
|
}, [
|
||||||
|
$el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: false,
|
||||||
|
onchange: (e: Event) => {
|
||||||
|
const isCropMode = (e.target as HTMLInputElement).checked;
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0) return;
|
||||||
|
|
||||||
|
selectedLayers.forEach((layer: Layer) => {
|
||||||
|
layer.cropMode = isCropMode;
|
||||||
|
if (isCropMode && !layer.cropBounds) {
|
||||||
|
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.saveState();
|
||||||
|
canvas.render();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("span.switch-track"),
|
||||||
|
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||||
|
$el("span.text-clipspace", {}, ["Crop"]),
|
||||||
|
$el("span.text-system", {}, ["Transform"])
|
||||||
|
]),
|
||||||
|
$el("span.switch-knob", {}, [
|
||||||
|
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}`})
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
input.checked,
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
false, // Initial state is transform
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return switchEl;
|
||||||
|
})(),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
@@ -362,13 +418,46 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement;
|
const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement;
|
||||||
if (button.classList.contains('loading')) return;
|
if (button.classList.contains('loading')) return;
|
||||||
|
|
||||||
const spinner = $el("div.matting-spinner") as HTMLDivElement;
|
|
||||||
button.appendChild(spinner);
|
|
||||||
button.classList.add('loading');
|
|
||||||
|
|
||||||
showInfoNotification("Starting background removal process...", 2000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// First check if model is available
|
||||||
|
const modelCheckResponse = await fetch("/matting/check-model");
|
||||||
|
const modelStatus = await modelCheckResponse.json();
|
||||||
|
|
||||||
|
if (!modelStatus.available) {
|
||||||
|
switch (modelStatus.reason) {
|
||||||
|
case 'missing_dependency':
|
||||||
|
showErrorNotification(modelStatus.message, 8000);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'not_downloaded':
|
||||||
|
showWarningNotification("The matting model needs to be downloaded first. This will happen automatically when you proceed (requires internet connection).", 5000);
|
||||||
|
|
||||||
|
// Ask user if they want to proceed with download
|
||||||
|
if (!confirm("The matting model needs to be downloaded (about 1GB). This is a one-time download. Do you want to proceed?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showInfoNotification("Downloading matting model... This may take a few minutes.", 10000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'corrupted':
|
||||||
|
showErrorNotification(modelStatus.message, 8000);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
showErrorNotification(`Error checking model: ${modelStatus.message}`, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with matting
|
||||||
|
const spinner = $el("div.matting-spinner") as HTMLDivElement;
|
||||||
|
button.appendChild(spinner);
|
||||||
|
button.classList.add('loading');
|
||||||
|
|
||||||
|
if (modelStatus.available) {
|
||||||
|
showInfoNotification("Starting background removal process...", 2000);
|
||||||
|
}
|
||||||
|
|
||||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||||
throw new Error("Please select exactly one image layer for matting.");
|
throw new Error("Please select exactly one image layer for matting.");
|
||||||
}
|
}
|
||||||
@@ -387,7 +476,18 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||||
if (result && result.error) {
|
if (result && result.error) {
|
||||||
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
|
// Handle specific error types
|
||||||
|
if (result.error === "Network Connection Error") {
|
||||||
|
showErrorNotification("Failed to download the matting model. Please check your internet connection and try again.", 8000);
|
||||||
|
return;
|
||||||
|
} else if (result.error === "Matting Model Error") {
|
||||||
|
showErrorNotification(result.details || "Model loading error. Please check the console for details.", 8000);
|
||||||
|
return;
|
||||||
|
} else if (result.error === "Dependency Not Found") {
|
||||||
|
showErrorNotification(result.details || "Missing required dependencies.", 8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorMsg = `${result.error}: ${result.details || 'Check console'}`;
|
||||||
}
|
}
|
||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
@@ -401,6 +501,10 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
|
|
||||||
|
// Invalidate processed image cache when layer image changes (matting)
|
||||||
|
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
|
||||||
|
|
||||||
canvas.render();
|
canvas.render();
|
||||||
canvas.saveState();
|
canvas.saveState();
|
||||||
showSuccessNotification("Background removed successfully!");
|
showSuccessNotification("Background removed successfully!");
|
||||||
@@ -408,10 +512,15 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
log.error("Matting error:", error);
|
log.error("Matting error:", error);
|
||||||
const errorMessage = error.message || "An unknown error occurred.";
|
const errorMessage = error.message || "An unknown error occurred.";
|
||||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
if (!errorMessage.includes("Network Connection Error") &&
|
||||||
|
!errorMessage.includes("Matting Model Error") &&
|
||||||
|
!errorMessage.includes("Dependency Not Found")) {
|
||||||
|
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
button.classList.remove('loading');
|
button.classList.remove('loading');
|
||||||
if (button.contains(spinner)) {
|
const spinner = button.querySelector('.matting-spinner');
|
||||||
|
if (spinner && button.contains(spinner)) {
|
||||||
button.removeChild(spinner);
|
button.removeChild(spinner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +545,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||||
$el("label.clipboard-switch.mask-switch", {
|
$el("label.clipboard-switch.mask-switch", {
|
||||||
id: `toggle-mask-switch-${node.id}`,
|
id: `toggle-mask-switch-${node.id}`,
|
||||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
|
||||||
|
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
|
||||||
}, [
|
}, [
|
||||||
$el("input", {
|
$el("input", {
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
@@ -521,6 +631,24 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
setTimeout(() => canvas.render(), 0);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||||
|
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
|
||||||
|
$el("input", {
|
||||||
|
id: "preview-opacity-slider",
|
||||||
|
type: "range",
|
||||||
|
min: "0",
|
||||||
|
max: "1",
|
||||||
|
step: "0.05",
|
||||||
|
value: "0.5",
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value;
|
||||||
|
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||||
|
const valueEl = document.getElementById('preview-opacity-value');
|
||||||
|
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("div.slider-value", {id: "preview-opacity-value"}, ["50%"])
|
||||||
|
]),
|
||||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||||
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
@@ -672,18 +800,50 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
const updateButtonStates = () => {
|
const updateButtonStates = () => {
|
||||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => {
|
|
||||||
const button = btn as HTMLButtonElement;
|
// --- Handle Standard Buttons ---
|
||||||
if (button.textContent === 'Fuse') {
|
controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
|
||||||
button.disabled = selectionCount < 2;
|
if (el.tagName === 'BUTTON') {
|
||||||
} else {
|
if (el.textContent === 'Fuse') {
|
||||||
button.disabled = !hasSelection;
|
el.disabled = selectionCount < 2;
|
||||||
|
} else {
|
||||||
|
el.disabled = !hasSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
|
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
|
||||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||||
mattingBtn.disabled = selectionCount !== 1;
|
mattingBtn.disabled = selectionCount !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Handle Crop/Transform Switch ---
|
||||||
|
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`) as HTMLLabelElement;
|
||||||
|
if (switchEl) {
|
||||||
|
const input = switchEl.querySelector('input') as HTMLInputElement;
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||||
|
|
||||||
|
const isDisabled = !hasSelection;
|
||||||
|
switchEl.classList.toggle('disabled', isDisabled);
|
||||||
|
input.disabled = isDisabled;
|
||||||
|
|
||||||
|
if (!isDisabled) {
|
||||||
|
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||||
|
if (input.checked !== isCropMode) {
|
||||||
|
input.checked = isCropMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon view
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
isCropMode,
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||||
@@ -840,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
resizeObserver.observe(controlsElement);
|
resizeObserver.observe(controlsElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch the canvas container itself to detect size changes and fix canvas dimensions
|
||||||
|
const canvasContainerResizeObserver = new ResizeObserver(() => {
|
||||||
|
// Force re-read of canvas dimensions on next render
|
||||||
|
canvas.render();
|
||||||
|
});
|
||||||
|
canvasContainerResizeObserver.observe(canvasContainer);
|
||||||
|
|
||||||
canvas.canvas.addEventListener('focus', () => {
|
canvas.canvas.addEventListener('focus', () => {
|
||||||
canvasContainer.classList.add('has-focus');
|
canvasContainer.classList.add('has-focus');
|
||||||
});
|
});
|
||||||
@@ -860,7 +1027,9 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
||||||
|
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
if (node.addDOMWidget) {
|
||||||
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
}
|
||||||
|
|
||||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
||||||
let backdrop: HTMLDivElement | null = null;
|
let backdrop: HTMLDivElement | null = null;
|
||||||
@@ -972,7 +1141,12 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
if (!(window as any).canvasExecutionStates) {
|
if (!(window as any).canvasExecutionStates) {
|
||||||
(window as any).canvasExecutionStates = new Map<string, any>();
|
(window as any).canvasExecutionStates = new Map<string, any>();
|
||||||
}
|
}
|
||||||
(node as any).canvasWidget = canvas;
|
|
||||||
|
// Store the entire widget object, not just the canvas
|
||||||
|
(node as any).canvasWidget = {
|
||||||
|
canvas: canvas,
|
||||||
|
panel: controlPanel
|
||||||
|
};
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
@@ -994,7 +1168,7 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
canvas.setPreviewVisibility(value);
|
canvas.setPreviewVisibility(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((node as any).graph && (node as any).graph.canvas) {
|
if ((node as any).graph && (node as any).graph.canvas && node.setDirtyCanvas) {
|
||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1014,7 +1188,7 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: "Comfy.CanvasNode",
|
name: "Comfy.LayerForgeNode",
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
addStylesheet(getUrl('./css/canvas_view.css'));
|
addStylesheet(getUrl('./css/canvas_view.css'));
|
||||||
@@ -1028,12 +1202,23 @@ app.registerExtension({
|
|||||||
|
|
||||||
const sendPromises: Promise<any>[] = [];
|
const sendPromises: Promise<any>[] = [];
|
||||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
const node = app.graph.getNodeById(nodeId);
|
||||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
|
||||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
if (!node) {
|
||||||
} else {
|
|
||||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||||
canvasNodeInstances.delete(nodeId);
|
canvasNodeInstances.delete(nodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip bypassed nodes
|
||||||
|
if (node.mode === 4) {
|
||||||
|
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||||
|
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1053,7 +1238,10 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
||||||
if (nodeType.comfyClass === "CanvasNode") {
|
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||||
|
// Map to track pending copy sources across node ID changes
|
||||||
|
const pendingCopySources = new Map<number, number>();
|
||||||
|
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
||||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||||
@@ -1087,9 +1275,198 @@ app.registerExtension({
|
|||||||
canvasNodeInstances.set(this.id, canvasWidget);
|
canvasNodeInstances.set(this.id, canvasWidget);
|
||||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
|
||||||
|
// Store the canvas widget on the node
|
||||||
|
(this as any).canvasWidget = canvasWidget;
|
||||||
|
|
||||||
|
// Check if this node has a pending copy source (from onConfigure)
|
||||||
|
// Check both the current ID and -1 (temporary ID during paste)
|
||||||
|
let sourceNodeId = pendingCopySources.get(this.id);
|
||||||
|
if (!sourceNodeId) {
|
||||||
|
sourceNodeId = pendingCopySources.get(-1);
|
||||||
|
if (sourceNodeId) {
|
||||||
|
// Transfer from -1 to the real ID and clear -1
|
||||||
|
pendingCopySources.delete(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceNodeId && sourceNodeId !== this.id) {
|
||||||
|
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
|
||||||
|
|
||||||
|
// Clear the flag
|
||||||
|
pendingCopySources.delete(this.id);
|
||||||
|
|
||||||
|
// Copy the canvas state now that the widget is initialized
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||||
|
let sourceState = await getCanvasState(String(sourceNodeId));
|
||||||
|
|
||||||
|
// If source node doesn't exist (cross-workflow paste), try clipboard
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
|
||||||
|
sourceState = await getCanvasState('__clipboard__');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found in clipboard either`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCanvasState(String(this.id), sourceState);
|
||||||
|
await canvasWidget.canvas.loadInitialState();
|
||||||
|
log.info(`Canvas state copied successfully to node ${this.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Error copying canvas state:`, error);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are already connected inputs
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setDirtyCanvas(true, true);
|
if (this.inputs && this.inputs.length > 0) {
|
||||||
}, 100);
|
// Check if input_image (index 0) is connected
|
||||||
|
if (this.inputs[0] && this.inputs[0].link) {
|
||||||
|
log.info("Input image already connected on node creation, checking for data...");
|
||||||
|
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||||
|
canvasWidget.canvas.inputDataLoaded = false;
|
||||||
|
// Only allow images on init; mask should load only on mask connect or execution
|
||||||
|
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.setDirtyCanvas) {
|
||||||
|
this.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onConnectionsChange handler to detect when inputs are connected
|
||||||
|
nodeType.prototype.onConnectionsChange = function (this: ComfyNode, type: number, index: number, connected: boolean, link_info: any) {
|
||||||
|
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
|
||||||
|
|
||||||
|
// Check if this is an input connection (type 1 = INPUT)
|
||||||
|
if (type === 1) {
|
||||||
|
// Get the canvas widget - it might be in different places
|
||||||
|
const canvasWidget = (this as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget?.canvas || canvasWidget;
|
||||||
|
|
||||||
|
if (!canvas || !canvas.canvasIO) {
|
||||||
|
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
|
||||||
|
// Retry multiple times with increasing delays
|
||||||
|
const retryDelays = [500, 1000, 2000];
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
const tryAgain = () => {
|
||||||
|
const retryCanvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (retryCanvas && retryCanvas.canvasIO) {
|
||||||
|
log.info("Canvas now ready, checking for input data...");
|
||||||
|
if (connected) {
|
||||||
|
retryCanvas.inputDataLoaded = false;
|
||||||
|
// Respect which input triggered the connection:
|
||||||
|
const opts = (index === 1)
|
||||||
|
? { allowImage: false, allowMask: true, reason: "mask_connect" }
|
||||||
|
: { allowImage: true, allowMask: false, reason: "image_connect" };
|
||||||
|
retryCanvas.canvasIO.checkForInputData(opts);
|
||||||
|
}
|
||||||
|
} else if (retryCount < retryDelays.length) {
|
||||||
|
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
} else {
|
||||||
|
log.error("Canvas failed to initialize after multiple retries");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(tryAgain, retryDelays[retryCount++]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_image connection (index 0)
|
||||||
|
if (index === 0) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input image connected, marking for data check...");
|
||||||
|
// Reset the input data loaded flag to allow loading the new connection
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
// Also reset the last loaded image source and link ID to allow the new image
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
// Mark that we have a pending input connection
|
||||||
|
canvas.hasPendingInputConnection = true;
|
||||||
|
|
||||||
|
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
|
||||||
|
if (!(this.inputs && this.inputs[1] && this.inputs[1].link)) {
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
canvas.maskTool.clear();
|
||||||
|
canvas.render();
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask because input_image connected without input_mask");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after connection...");
|
||||||
|
// Only load images here; masks should not auto-load on image connect
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input image disconnected");
|
||||||
|
canvas.hasPendingInputConnection = false;
|
||||||
|
// Reset when disconnected so a new connection can load
|
||||||
|
canvas.inputDataLoaded = false;
|
||||||
|
canvas.lastLoadedImageSrc = undefined;
|
||||||
|
canvas.lastLoadedLinkId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input_mask connection (index 1)
|
||||||
|
if (index === 1) {
|
||||||
|
if (connected && link_info) {
|
||||||
|
log.info("Input mask connected");
|
||||||
|
|
||||||
|
// DON'T clear existing mask when connecting a new input
|
||||||
|
// Reset the loaded mask link ID to allow loading from the new connection
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
|
||||||
|
// Mark that we have a pending mask connection
|
||||||
|
canvas.hasPendingMaskConnection = true;
|
||||||
|
// Check for data immediately when connected
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Checking for input data after mask connection...");
|
||||||
|
// Only load mask here if it's immediately available from the connected node
|
||||||
|
// Don't load stale masks from backend storage
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
log.info("Input mask disconnected");
|
||||||
|
canvas.hasPendingMaskConnection = false;
|
||||||
|
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
|
||||||
|
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
|
||||||
|
(canvas as any).maskAppliedFromInput = false;
|
||||||
|
canvas.lastLoadedMaskLinkId = undefined;
|
||||||
|
log.info("Cleared auto-applied mask due to mask input disconnection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add onExecuted handler to check for input data after workflow execution
|
||||||
|
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||||
|
nodeType.prototype.onExecuted = function (this: ComfyNode, message: any) {
|
||||||
|
log.info("Node executed, checking for input data...");
|
||||||
|
|
||||||
|
const canvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
|
||||||
|
if (canvas && canvas.canvasIO) {
|
||||||
|
// Don't reset inputDataLoaded - just check for new data
|
||||||
|
// On execution we allow both image and mask to load
|
||||||
|
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
originalOnExecuted.apply(this, arguments as any);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
@@ -1127,6 +1504,52 @@ app.registerExtension({
|
|||||||
return onRemoved?.apply(this, arguments as any);
|
return onRemoved?.apply(this, arguments as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle copy/paste - save canvas state when copying
|
||||||
|
const originalSerialize = nodeType.prototype.serialize;
|
||||||
|
nodeType.prototype.serialize = function (this: ComfyNode) {
|
||||||
|
const data = originalSerialize ? originalSerialize.apply(this) : {};
|
||||||
|
|
||||||
|
// Store a reference to the source node ID so we can copy layer data
|
||||||
|
data.sourceNodeId = this.id;
|
||||||
|
log.debug(`Serializing node ${this.id} for copy`);
|
||||||
|
|
||||||
|
// Store canvas state in a clipboard entry for cross-workflow paste
|
||||||
|
// This happens async but that's fine since paste happens later
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||||
|
const sourceState = await getCanvasState(String(this.id));
|
||||||
|
if (sourceState) {
|
||||||
|
// Store in a special "clipboard" entry
|
||||||
|
await setCanvasState('__clipboard__', sourceState);
|
||||||
|
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error storing canvas state to clipboard:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle copy/paste - load canvas state from source node when pasting
|
||||||
|
const originalConfigure = nodeType.prototype.onConfigure;
|
||||||
|
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
|
||||||
|
if (originalConfigure) {
|
||||||
|
originalConfigure.apply(this, [data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the source node ID in the map (persists across node ID changes)
|
||||||
|
// This will be picked up later in onAdded when the canvas widget is ready
|
||||||
|
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
|
||||||
|
const existingSource = pendingCopySources.get(this.id);
|
||||||
|
if (!existingSource) {
|
||||||
|
pendingCopySources.set(this.id, data.sourceNodeId);
|
||||||
|
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||||
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
||||||
// FIRST: Call original to let other extensions add their options
|
// FIRST: Call original to let other extensions add their options
|
||||||
@@ -1226,8 +1649,8 @@ app.registerExtension({
|
|||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
log.info("Opening LayerForge canvas in MaskEditor");
|
log.info("Opening LayerForge canvas in MaskEditor");
|
||||||
if ((self as any).canvasWidget && (self as any).canvasWidget.startMaskEditor) {
|
if ((self as any).canvasWidget && (self as any).canvasWidget.canvas) {
|
||||||
await (self as any).canvasWidget.startMaskEditor(null, true);
|
await (self as any).canvasWidget.canvas.startMaskEditor(null, true);
|
||||||
} else {
|
} else {
|
||||||
log.error("Canvas widget not available");
|
log.error("Canvas widget not available");
|
||||||
showErrorNotification("Canvas not ready. Please try again.");
|
showErrorNotification("Canvas not ready. Please try again.");
|
||||||
@@ -1242,8 +1665,8 @@ app.registerExtension({
|
|||||||
content: "Open Image",
|
content: "Open Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
@@ -1257,8 +1680,8 @@ app.registerExtension({
|
|||||||
content: "Open Image with Mask Alpha",
|
content: "Open Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
@@ -1272,8 +1695,8 @@ app.registerExtension({
|
|||||||
content: "Copy Image",
|
content: "Copy Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const item = new ClipboardItem({'image/png': blob});
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
await navigator.clipboard.write([item]);
|
await navigator.clipboard.write([item]);
|
||||||
@@ -1288,8 +1711,8 @@ app.registerExtension({
|
|||||||
content: "Copy Image with Mask Alpha",
|
content: "Copy Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const item = new ClipboardItem({'image/png': blob});
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
await navigator.clipboard.write([item]);
|
await navigator.clipboard.write([item]);
|
||||||
@@ -1304,8 +1727,8 @@ app.registerExtension({
|
|||||||
content: "Save Image",
|
content: "Save Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -1324,8 +1747,8 @@ app.registerExtension({
|
|||||||
content: "Save Image with Mask Alpha",
|
content: "Save Image with Mask Alpha",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
try {
|
try {
|
||||||
if (!(self as any).canvasWidget) return;
|
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
if (!blob) return;
|
if (!blob) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@@ -507,7 +507,6 @@ export class MaskEditorIntegration {
|
|||||||
maskSize: {width: bounds.width, height: bounds.height}
|
maskSize: {width: bounds.width, height: bounds.height}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the chunk system instead of direct canvas manipulation
|
|
||||||
this.maskTool.setMask(maskAsImage);
|
this.maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
|
|||||||
287
src/MaskTool.ts
287
src/MaskTool.ts
@@ -21,9 +21,10 @@ interface MaskChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
private brushHardness: number;
|
private _brushHardness: number;
|
||||||
private brushSize: number;
|
public brushSize: number;
|
||||||
private brushStrength: number;
|
private _brushStrength: number;
|
||||||
|
private _previewOpacity: number;
|
||||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||||
public isActive: boolean;
|
public isActive: boolean;
|
||||||
public isDrawing: boolean;
|
public isDrawing: boolean;
|
||||||
@@ -31,6 +32,9 @@ export class MaskTool {
|
|||||||
private lastPosition: Point | null;
|
private lastPosition: Point | null;
|
||||||
private mainCanvas: HTMLCanvasElement;
|
private mainCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
private currentStrokePoints: Point[] = [];
|
||||||
|
|
||||||
// Chunked mask system
|
// Chunked mask system
|
||||||
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
||||||
private chunkSize: number;
|
private chunkSize: number;
|
||||||
@@ -72,6 +76,9 @@ export class MaskTool {
|
|||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
|
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -96,8 +103,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
|
|
||||||
@@ -156,8 +164,31 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Getters for brush properties
|
||||||
|
get brushStrength(): number {
|
||||||
|
return this._brushStrength;
|
||||||
|
}
|
||||||
|
|
||||||
|
get brushHardness(): number {
|
||||||
|
return this._brushHardness;
|
||||||
|
}
|
||||||
|
|
||||||
|
get previewOpacity(): number {
|
||||||
|
return this._previewOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
setBrushHardness(hardness: number): void {
|
setBrushHardness(hardness: number): void {
|
||||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewOpacity(opacity: number): void {
|
||||||
|
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||||
|
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||||
|
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||||
|
}
|
||||||
|
// Trigger canvas render to update mask display opacity
|
||||||
|
this.canvasInstance.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
initMaskCanvas(): void {
|
initMaskCanvas(): void {
|
||||||
@@ -867,7 +898,7 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBrushStrength(strength: number): void {
|
setBrushStrength(strength: number): void {
|
||||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
||||||
@@ -875,10 +906,12 @@ export class MaskTool {
|
|||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
|
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
|
|
||||||
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.draw(worldCoords);
|
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,16 +921,83 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing) return;
|
if (!this.isActive || !this.isDrawing) return;
|
||||||
|
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints.push(worldCoords);
|
||||||
|
|
||||||
|
// Draw interpolated segments for smooth strokes without gaps
|
||||||
|
if (this.lastPosition) {
|
||||||
|
// Calculate distance between last and current position
|
||||||
|
const dx = worldCoords.x - this.lastPosition.x;
|
||||||
|
const dy = worldCoords.y - this.lastPosition.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// If distance is small, just draw a single segment
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||||
|
} else {
|
||||||
|
// Interpolate points for smooth drawing without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||||
|
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(
|
||||||
|
interpolatedPoints[i],
|
||||||
|
interpolatedPoints[i + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.draw(worldCoords);
|
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolates points between two positions to create smooth strokes without gaps
|
||||||
|
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||||
|
*/
|
||||||
|
private interpolatePoints(start: Point, end: Point, distance: number): Point[] {
|
||||||
|
const points: Point[] = [];
|
||||||
|
|
||||||
|
// Calculate number of interpolated points based on brush size
|
||||||
|
// More points = smoother line
|
||||||
|
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||||
|
const numSteps = Math.ceil(distance / stepSize);
|
||||||
|
|
||||||
|
// Always include start point
|
||||||
|
points.push(start);
|
||||||
|
|
||||||
|
// Interpolate intermediate points
|
||||||
|
for (let i = 1; i < numSteps; i++) {
|
||||||
|
const t = i / numSteps;
|
||||||
|
points.push({
|
||||||
|
x: start.x + (end.x - start.x) * t,
|
||||||
|
y: start.y + (end.y - start.y) * t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include end point
|
||||||
|
points.push(end);
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when viewport changes during drawing to update stroke overlay
|
||||||
|
* This ensures the stroke preview scales correctly with zoom changes
|
||||||
|
*/
|
||||||
|
handleViewportChange(): void {
|
||||||
|
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||||
|
// Redraw the entire stroke overlay with new viewport settings
|
||||||
|
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseLeave(): void {
|
handleMouseLeave(): void {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter(): void {
|
handleMouseEnter(): void {
|
||||||
@@ -908,11 +1008,18 @@ export class MaskTool {
|
|||||||
if (!this.isActive) return;
|
if (!this.isActive) return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
|
|
||||||
|
// Commit the stroke from overlay to actual mask chunks
|
||||||
|
this.commitStrokeToChunks();
|
||||||
|
|
||||||
|
// Clear stroke overlay and reset state
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
|
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// After drawing is complete, update active canvas to show all chunks
|
||||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
this.updateActiveMaskCanvas(true); // Force full update
|
||||||
|
|
||||||
this.completeMaskOperation();
|
this.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
@@ -932,6 +1039,44 @@ export class MaskTool {
|
|||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits the current stroke from overlay to actual mask chunks
|
||||||
|
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||||
|
*/
|
||||||
|
private commitStrokeToChunks(): void {
|
||||||
|
if (this.currentStrokePoints.length < 2) {
|
||||||
|
return; // Need at least 2 points for a stroke
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||||
|
|
||||||
|
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||||
|
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||||
|
const startPoint = this.currentStrokePoints[i - 1];
|
||||||
|
const endPoint = this.currentStrokePoints[i];
|
||||||
|
|
||||||
|
// Calculate distance between points
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
// Small distance - draw single segment
|
||||||
|
this.drawOnChunks(startPoint, endPoint);
|
||||||
|
} else {
|
||||||
|
// Large distance - interpolate for smooth line without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||||
|
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||||
|
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws a line between two world coordinates on the appropriate chunks
|
* Draws a line between two world coordinates on the appropriate chunks
|
||||||
*/
|
*/
|
||||||
@@ -982,15 +1127,15 @@ export class MaskTool {
|
|||||||
|
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
|
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
} else {
|
} else {
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this._brushHardness;
|
||||||
const gradient = chunk.ctx.createRadialGradient(
|
const gradient = chunk.ctx.createRadialGradient(
|
||||||
endLocal.x, endLocal.y, innerRadius,
|
endLocal.x, endLocal.y, innerRadius,
|
||||||
endLocal.x, endLocal.y, gradientRadius
|
endLocal.x, endLocal.y, gradientRadius
|
||||||
);
|
);
|
||||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -1029,29 +1174,17 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
|
||||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
|
||||||
|
|
||||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
|
||||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
|
||||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
|
||||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
|
||||||
|
|
||||||
// During drawing, only update affected chunks that are active for performance
|
|
||||||
if (this.isDrawing) {
|
|
||||||
// Use throttled partial update for active chunks only
|
|
||||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
|
||||||
} else {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
this.updateActiveMaskCanvas(true);
|
||||||
}
|
}
|
||||||
|
// During drawing, we don't update chunks at all - overlay handles preview
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1142,20 +1275,13 @@ export class MaskTool {
|
|||||||
|
|
||||||
drawBrushPreview(viewCoords: Point): void {
|
drawBrushPreview(viewCoords: Point): void {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
|
|
||||||
this.previewCtx.beginPath();
|
|
||||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
|
||||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
||||||
this.previewCtx.lineWidth = 1;
|
|
||||||
this.previewCtx.setLineDash([2, 4]);
|
|
||||||
this.previewCtx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPreview(): void {
|
clearPreview(): void {
|
||||||
@@ -1548,6 +1674,27 @@ export class MaskTool {
|
|||||||
log.info("Cleared all mask data from all chunks");
|
log.info("Cleared all mask data from all chunks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all chunks and restores mask from saved state
|
||||||
|
* This is used during undo/redo operations to ensure clean state restoration
|
||||||
|
*/
|
||||||
|
restoreMaskFromSavedState(savedMaskCanvas: HTMLCanvasElement): void {
|
||||||
|
// First, clear ALL chunks to ensure no leftover data
|
||||||
|
this.clearAllMaskChunks();
|
||||||
|
|
||||||
|
// Now apply the saved mask state to chunks
|
||||||
|
if (savedMaskCanvas.width > 0 && savedMaskCanvas.height > 0) {
|
||||||
|
// Apply the saved mask to the chunk system at the correct position
|
||||||
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
|
this.applyMaskCanvasToChunks(savedMaskCanvas, this.x, this.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the active mask canvas to show the restored state
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
|
||||||
|
log.debug("Restored mask from saved state with clean chunk system");
|
||||||
|
}
|
||||||
|
|
||||||
getMask(): HTMLCanvasElement {
|
getMask(): HTMLCanvasElement {
|
||||||
// Return the current active mask canvas which shows all chunks
|
// Return the current active mask canvas which shows all chunks
|
||||||
// Only update if there are pending changes to avoid unnecessary redraws
|
// Only update if there are pending changes to avoid unnecessary redraws
|
||||||
@@ -1667,15 +1814,47 @@ export class MaskTool {
|
|||||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMask(image: HTMLImageElement): void {
|
setMask(image: HTMLImageElement, isFromInputMask: boolean = false): void {
|
||||||
// Clear existing mask chunks in the output area first
|
|
||||||
const bounds = this.canvasInstance.outputAreaBounds;
|
const bounds = this.canvasInstance.outputAreaBounds;
|
||||||
this.clearMaskInArea(bounds.x, bounds.y, image.width, image.height);
|
|
||||||
|
|
||||||
// Add the new mask using the chunk system
|
if (isFromInputMask) {
|
||||||
this.addMask(image);
|
// For INPUT MASK - process black background to transparent using luminance
|
||||||
|
// Center like input images
|
||||||
log.info(`MaskTool set new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
const centerX = bounds.x + (bounds.width - image.width) / 2;
|
||||||
|
const centerY = bounds.y + (bounds.height - image.height) / 2;
|
||||||
|
|
||||||
|
// Prepare mask where alpha = luminance (white = applied, black = transparent)
|
||||||
|
const { canvas: maskCanvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create mask processing context");
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
const imgData = ctx.getImageData(0, 0, image.width, image.height);
|
||||||
|
const data = imgData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||||
|
const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
|
data[i] = 255; // force white color (color channels ignored downstream)
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = lum; // alpha encodes mask strength: white -> strong, black -> 0
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
// Clear target area and apply to chunked system at centered position
|
||||||
|
this.clearMaskInArea(centerX, centerY, image.width, image.height);
|
||||||
|
this.applyMaskCanvasToChunks(maskCanvas, centerX, centerY);
|
||||||
|
|
||||||
|
// Refresh state and UI
|
||||||
|
this.updateActiveMaskCanvas(true);
|
||||||
|
this.canvasInstance.canvasState.saveMaskState();
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool set INPUT MASK at centered position (${centerX}, ${centerY}) using luminance as alpha`);
|
||||||
|
} else {
|
||||||
|
// For SAM Detector and other sources - just clear and add without processing
|
||||||
|
this.clearMaskInArea(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||||
|
this.addMask(image);
|
||||||
|
log.info(`MaskTool set mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-ignore
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { ComfyApp } from "../../scripts/app.js";
|
import { ComfyApp } from "../../scripts/app.js";
|
||||||
@@ -7,6 +8,7 @@ import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.j
|
|||||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||||
import { convertToImage } from "./utils/ImageUtils.js";
|
import { convertToImage } from "./utils/ImageUtils.js";
|
||||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||||
|
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
|
||||||
import type { ComfyNode } from './types';
|
import type { ComfyNode } from './types';
|
||||||
|
|
||||||
const log = createModuleLogger('SAMDetectorIntegration');
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
@@ -281,36 +283,61 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
log.debug("Attempting to reload SAM result image");
|
log.debug("Attempting to reload SAM result image");
|
||||||
const originalSrc = resultImage.src;
|
const originalSrc = resultImage.src;
|
||||||
|
|
||||||
// Add cache-busting parameter to force fresh load
|
// Check if it's a data URL (base64) - don't add parameters to data URLs
|
||||||
const url = new URL(originalSrc);
|
if (originalSrc.startsWith('data:')) {
|
||||||
url.searchParams.set('_t', Date.now().toString());
|
log.debug("Image is a data URL, skipping reload with parameters");
|
||||||
|
// For data URLs, just ensure the image is loaded
|
||||||
await new Promise((resolve, reject) => {
|
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||||
const img = new Image();
|
await new Promise((resolve, reject) => {
|
||||||
img.crossOrigin = "anonymous";
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
// Copy the loaded image data to the original image
|
resultImage.width = img.width;
|
||||||
resultImage.src = img.src;
|
resultImage.height = img.height;
|
||||||
resultImage.width = img.width;
|
log.debug("Data URL image loaded successfully", {
|
||||||
resultImage.height = img.height;
|
width: img.width,
|
||||||
log.debug("SAM result image reloaded successfully", {
|
height: img.height
|
||||||
width: img.width,
|
});
|
||||||
height: img.height,
|
resolve(img);
|
||||||
originalSrc: originalSrc,
|
};
|
||||||
newSrc: img.src
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load data URL image", error);
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = originalSrc; // Use original src without modifications
|
||||||
});
|
});
|
||||||
resolve(img);
|
}
|
||||||
};
|
} else {
|
||||||
img.onerror = (error) => {
|
// For regular URLs, add cache-busting parameter
|
||||||
log.error("Failed to reload SAM result image", {
|
const url = new URL(originalSrc);
|
||||||
originalSrc: originalSrc,
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
newSrc: url.toString(),
|
|
||||||
error: error
|
await new Promise((resolve, reject) => {
|
||||||
});
|
const img = new Image();
|
||||||
reject(error);
|
img.crossOrigin = "anonymous";
|
||||||
};
|
img.onload = () => {
|
||||||
img.src = url.toString();
|
// Copy the loaded image data to the original image
|
||||||
});
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to load image from SAM Detector.", error);
|
log.error("Failed to load image from SAM Detector.", error);
|
||||||
@@ -332,32 +359,43 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
log.debug("Checking canvas and maskTool availability", {
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasCanvasProperty: !!canvas.canvas,
|
||||||
|
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||||
hasMaskTool: !!canvas.maskTool,
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||||
maskToolType: typeof canvas.maskTool,
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas)
|
canvasKeys: Object.keys(canvas)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canvas.maskTool) {
|
// Get the actual Canvas object and its maskTool
|
||||||
|
const actualCanvas = canvas.canvas || canvas;
|
||||||
|
const maskTool = actualCanvas.maskTool;
|
||||||
|
|
||||||
|
if (!maskTool) {
|
||||||
log.error("MaskTool is not available. Canvas state:", {
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
hasCanvas: !!canvas,
|
hasCanvas: !!canvas,
|
||||||
|
hasActualCanvas: !!actualCanvas,
|
||||||
canvasConstructor: canvas.constructor.name,
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||||
canvasKeys: Object.keys(canvas),
|
canvasKeys: Object.keys(canvas),
|
||||||
maskToolValue: canvas.maskTool
|
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||||
|
maskToolValue: maskTool
|
||||||
});
|
});
|
||||||
throw new Error("Mask tool not available or not initialized");
|
throw new Error("Mask tool not available or not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("Applying SAM mask to canvas using addMask method");
|
log.debug("Applying SAM mask to canvas using setMask method");
|
||||||
|
|
||||||
// Use the addMask method which overlays on existing mask without clearing it
|
// Use the setMask method which clears existing mask and sets new one
|
||||||
canvas.maskTool.addMask(maskAsImage);
|
maskTool.setMask(maskAsImage);
|
||||||
|
|
||||||
// Update canvas and save state (same as MaskEditorIntegration)
|
// Update canvas and save state (same as MaskEditorIntegration)
|
||||||
canvas.render();
|
actualCanvas.render();
|
||||||
canvas.saveState();
|
actualCanvas.saveState();
|
||||||
|
|
||||||
// Update node preview using PreviewUtils
|
// Update node preview using PreviewUtils
|
||||||
await updateNodePreview(canvas, node, true);
|
await updateNodePreview(actualCanvas, node, true);
|
||||||
|
|
||||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
|
|
||||||
@@ -376,6 +414,9 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Store original onClipspaceEditorSave function to restore later
|
||||||
|
let originalOnClipspaceEditorSave: (() => void) | null = null;
|
||||||
|
|
||||||
// Function to setup SAM Detector hook in menu options
|
// Function to setup SAM Detector hook in menu options
|
||||||
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
@@ -395,22 +436,67 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
|||||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
|
|
||||||
// Automatically send canvas to clipspace and start monitoring
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
if ((node as any).canvasWidget) {
|
||||||
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
const canvasWidget = (node as any).canvasWidget;
|
||||||
|
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
|
||||||
|
|
||||||
// Use ImageUploadUtils to upload canvas
|
// Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
|
||||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||||
filenamePrefix: 'layerforge-sam',
|
filenamePrefix: 'layerforge-sam',
|
||||||
nodeId: node.id
|
nodeId: node.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug("Uploaded canvas for SAM Detector", {
|
||||||
|
filename: uploadResult.filename,
|
||||||
|
imageUrl: uploadResult.imageUrl,
|
||||||
|
width: uploadResult.imageElement.width,
|
||||||
|
height: uploadResult.imageElement.height
|
||||||
|
});
|
||||||
|
|
||||||
// Set the image to the node for clipspace
|
// Set the image to the node for clipspace
|
||||||
node.imgs = [uploadResult.imageElement];
|
node.imgs = [uploadResult.imageElement];
|
||||||
(node as any).clipspaceImg = uploadResult.imageElement;
|
(node as any).clipspaceImg = uploadResult.imageElement;
|
||||||
|
|
||||||
|
// Ensure proper clipspace structure for updated ComfyUI
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
ComfyApp.clipspace = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up clipspace with proper indices
|
||||||
|
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
|
|
||||||
// Copy to ComfyUI clipspace
|
// Copy to ComfyUI clipspace
|
||||||
ComfyApp.copyToClipspace(node);
|
ComfyApp.copyToClipspace(node);
|
||||||
|
|
||||||
|
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||||
|
if (!originalOnClipspaceEditorSave) {
|
||||||
|
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||||
|
ComfyApp.onClipspaceEditorSave = function() {
|
||||||
|
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||||
|
|
||||||
|
// Use the unified clipspace validation function
|
||||||
|
const isValid = validateAndFixClipspace();
|
||||||
|
if (!isValid) {
|
||||||
|
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the original function
|
||||||
|
if (originalOnClipspaceEditorSave) {
|
||||||
|
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the original function after use
|
||||||
|
if (originalOnClipspaceEditorSave) {
|
||||||
|
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||||
|
originalOnClipspaceEditorSave = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Start monitoring for SAM Detector results
|
// Start monitoring for SAM Detector results
|
||||||
startSAMDetectorMonitoring(node);
|
startSAMDetectorMonitoring(node);
|
||||||
|
|
||||||
|
|||||||
170
src/css/blend_mode_menu.css
Normal file
170
src/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* Blend Mode Menu Styles */
|
||||||
|
#blend-mode-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-bar {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-title-text {
|
||||||
|
flex: 1;
|
||||||
|
cursor: move;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-close-button:focus {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-menu-content {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-container {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-label {
|
||||||
|
color: white;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option:hover {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-option.active {
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
display: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #555;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 550;
|
font-weight: 550;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
border-color: #3a76d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Crop mode button styling */
|
||||||
|
.painter-button#crop-mode-btn {
|
||||||
|
background-color: #444;
|
||||||
|
border-color: #555;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn.primary {
|
||||||
|
background-color: #0080ff;
|
||||||
|
border-color: #0070e0;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn.primary:hover {
|
||||||
|
background-color: #1090ff;
|
||||||
|
border-color: #0080ff;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-button#crop-mode-btn:hover {
|
||||||
|
background-color: #555;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.painter-button.success {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
@@ -187,7 +213,7 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -306,6 +332,25 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled state for switch */
|
||||||
|
.clipboard-switch.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #3a3a3a !important; /* Override gradient */
|
||||||
|
border-color: #4a4a4a !important;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-knob {
|
||||||
|
background-color: #4a4a4a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-switch.disabled .switch-labels {
|
||||||
|
color: #777 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -593,7 +638,7 @@
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 111;
|
z-index: 999999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
309
src/css/layers_panel.css
Normal file
309
src/css/layers_panel.css
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/* Layers Panel Styles */
|
||||||
|
.layers-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container .custom-checkbox {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:checked ~ .custom-checkbox {
|
||||||
|
background-color: #3a76d6;
|
||||||
|
border-color: #3a76d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container .custom-checkbox::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
left: 5px;
|
||||||
|
top: 1px;
|
||||||
|
width: 4px;
|
||||||
|
height: 9px;
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:checked ~ .custom-checkbox::after {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:indeterminate ~ .custom-checkbox {
|
||||||
|
background-color: #3a76d6;
|
||||||
|
border-color: #3a76d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
|
||||||
|
display: block;
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
left: 3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: white;
|
||||||
|
border: none;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:active {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #666666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.selected {
|
||||||
|
background: #2d5aa0 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name.editing {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border: 1px solid #6a6a6a;
|
||||||
|
outline: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-insertion-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #4a7bc8;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-visibility-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container styles */
|
||||||
|
.layers-panel .icon-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.visibility-hidden img {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel .icon-container.fallback-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
36
src/types.ts
36
src/types.ts
@@ -1,6 +1,14 @@
|
|||||||
import type { Canvas as CanvasClass } from './Canvas';
|
import type { Canvas as CanvasClass } from './Canvas';
|
||||||
import type { CanvasLayers } from './CanvasLayers';
|
import type { CanvasLayers } from './CanvasLayers';
|
||||||
|
|
||||||
|
export interface ComfyWidget {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
value: any;
|
||||||
|
callback?: (value: any) => void;
|
||||||
|
options?: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Layer {
|
export interface Layer {
|
||||||
id: string;
|
id: string;
|
||||||
image: HTMLImageElement;
|
image: HTMLImageElement;
|
||||||
@@ -21,19 +29,27 @@ export interface Layer {
|
|||||||
flipH?: boolean;
|
flipH?: boolean;
|
||||||
flipV?: boolean;
|
flipV?: boolean;
|
||||||
blendArea?: number;
|
blendArea?: number;
|
||||||
|
cropMode?: boolean; // czy warstwa jest w trybie crop
|
||||||
|
cropBounds?: { // granice przycinania
|
||||||
|
x: number; // offset od lewej krawędzi obrazu
|
||||||
|
y: number; // offset od górnej krawędzi obrazu
|
||||||
|
width: number; // szerokość widocznego obszaru
|
||||||
|
height: number; // wysokość widocznego obszaru
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComfyNode {
|
export interface ComfyNode {
|
||||||
id: number;
|
id: number;
|
||||||
|
type: string;
|
||||||
|
widgets: ComfyWidget[];
|
||||||
imgs?: HTMLImageElement[];
|
imgs?: HTMLImageElement[];
|
||||||
widgets: any[];
|
size?: [number, number];
|
||||||
size: [number, number];
|
|
||||||
graph: any;
|
|
||||||
canvasWidget?: any;
|
|
||||||
onResize?: () => void;
|
onResize?: () => void;
|
||||||
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
|
setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
|
||||||
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
|
graph?: any;
|
||||||
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
|
onRemoved?: () => void;
|
||||||
|
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
|
||||||
|
inputs?: Array<{ link: any }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -72,8 +88,14 @@ export interface Canvas {
|
|||||||
imageCache: any;
|
imageCache: any;
|
||||||
dataInitialized: boolean;
|
dataInitialized: boolean;
|
||||||
pendingDataCheck: number | null;
|
pendingDataCheck: number | null;
|
||||||
|
pendingInputDataCheck: number | null;
|
||||||
pendingBatchContext: any;
|
pendingBatchContext: any;
|
||||||
canvasLayers: any;
|
canvasLayers: any;
|
||||||
|
inputDataLoaded: boolean;
|
||||||
|
lastLoadedLinkId: any;
|
||||||
|
lastLoadedMaskLinkId: any;
|
||||||
|
lastLoadedImageSrc?: string;
|
||||||
|
outputAreaBounds: OutputAreaBounds;
|
||||||
saveState: () => void;
|
saveState: () => void;
|
||||||
render: () => void;
|
render: () => void;
|
||||||
updateSelection: (layers: Layer[]) => void;
|
updateSelection: (layers: Layer[]) => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {createModuleLogger} from "./LoggerUtils.js";
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||||
|
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import {api} from "../../../scripts/api.js";
|
import {api} from "../../../scripts/api.js";
|
||||||
@@ -33,6 +34,7 @@ export class ClipboardManager {
|
|||||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
log.info("Found layers in internal clipboard, pasting layers");
|
log.info("Found layers in internal clipboard, pasting layers");
|
||||||
this.canvas.canvasLayers.pasteLayers();
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
showInfoNotification("Layers pasted from internal clipboard");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +45,22 @@ export class ClipboardManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
log.info("No image found in ComfyUI Clipspace");
|
log.info("No image found in ComfyUI Clipspace");
|
||||||
|
// Don't show error here, will try system clipboard next
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Attempting paste from system clipboard");
|
log.info("Attempting paste from system clipboard");
|
||||||
return await this.trySystemClipboardPaste(addMode);
|
const systemSuccess = await this.trySystemClipboardPaste(addMode);
|
||||||
|
|
||||||
|
if (!systemSuccess) {
|
||||||
|
// No valid image found in any clipboard
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
showWarningNotification("No valid image found in Clipspace or system clipboard");
|
||||||
|
} else {
|
||||||
|
showWarningNotification("No valid image found in clipboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemSuccess;
|
||||||
}, 'ClipboardManager.handlePaste');
|
}, 'ClipboardManager.handlePaste');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +70,13 @@ export class ClipboardManager {
|
|||||||
*/
|
*/
|
||||||
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
||||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
|
||||||
|
// Use the unified clipspace validation and paste function
|
||||||
|
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
||||||
|
if (!pasteSuccess) {
|
||||||
|
log.debug("Safe clipspace paste failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
const clipspaceImage = this.canvas.node.imgs[0];
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
@@ -65,6 +85,7 @@ export class ClipboardManager {
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from Clipspace");
|
||||||
};
|
};
|
||||||
img.src = clipspaceImage.src;
|
img.src = clipspaceImage.src;
|
||||||
return true;
|
return true;
|
||||||
@@ -98,6 +119,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from system clipboard");
|
log.info("Successfully loaded image from system clipboard");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from system clipboard");
|
||||||
};
|
};
|
||||||
if (event.target?.result) {
|
if (event.target?.result) {
|
||||||
img.src = event.target.result as string;
|
img.src = event.target.result as string;
|
||||||
@@ -141,11 +163,22 @@ export class ClipboardManager {
|
|||||||
const text = await navigator.clipboard.readText();
|
const text = await navigator.clipboard.readText();
|
||||||
log.debug("Found text in clipboard:", text);
|
log.debug("Found text in clipboard:", text);
|
||||||
|
|
||||||
if (text && this.isValidImagePath(text)) {
|
if (text) {
|
||||||
log.info("Found valid image path in clipboard:", text);
|
// Check if it's a data URI (base64 encoded image)
|
||||||
const success = await this.loadImageFromPath(text, addMode);
|
if (this.isDataURI(text)) {
|
||||||
if (success) {
|
log.info("Found data URI in clipboard");
|
||||||
return true;
|
const success = await this.loadImageFromDataURI(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if it's a regular file path or URL
|
||||||
|
else if (this.isValidImagePath(text)) {
|
||||||
|
log.info("Found valid image path in clipboard:", text);
|
||||||
|
const success = await this.loadImageFromPath(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -158,6 +191,50 @@ export class ClipboardManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a text string is a data URI (base64 encoded image)
|
||||||
|
* @param {string} text - The text to check
|
||||||
|
* @returns {boolean} - True if the text is a data URI
|
||||||
|
*/
|
||||||
|
isDataURI(text: string): boolean {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it starts with data:image
|
||||||
|
return text.trim().startsWith('data:image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an image from a data URI (base64 encoded image)
|
||||||
|
* @param {string} dataURI - The data URI to load
|
||||||
|
* @param {AddMode} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async loadImageFromDataURI(dataURI: string, addMode: AddMode): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from data URI");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image pasted from clipboard (base64)");
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load image from data URI");
|
||||||
|
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
img.src = dataURI;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error loading data URI:", error);
|
||||||
|
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if a text string is a valid image file path or URL
|
* Validates if a text string is a valid image file path or URL
|
||||||
* @param {string} text - The text to validate
|
* @param {string} text - The text to validate
|
||||||
@@ -233,15 +310,17 @@ export class ClipboardManager {
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = 'anonymous';
|
img.crossOrigin = 'anonymous';
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from URL");
|
log.info("Successfully loaded image from URL");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
resolve(true);
|
showInfoNotification("Image loaded from URL");
|
||||||
};
|
resolve(true);
|
||||||
img.onerror = () => {
|
};
|
||||||
log.warn("Failed to load image from URL:", filePath);
|
img.onerror = () => {
|
||||||
resolve(false);
|
log.warn("Failed to load image from URL:", filePath);
|
||||||
};
|
showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
img.src = filePath;
|
img.src = filePath;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -319,6 +398,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from backend response");
|
log.info("Successfully loaded image from backend response");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image loaded from file path");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
@@ -359,6 +439,7 @@ export class ClipboardManager {
|
|||||||
img.onload = async () => {
|
img.onload = async () => {
|
||||||
log.info("Successfully loaded image from file picker");
|
log.info("Successfully loaded image from file picker");
|
||||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
showInfoNotification("Image loaded from selected file");
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
|
|||||||
114
src/utils/ClipspaceUtils.ts
Normal file
114
src/utils/ClipspaceUtils.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('ClipspaceUtils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||||
|
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||||
|
*/
|
||||||
|
export function validateAndFixClipspace(): boolean {
|
||||||
|
log.debug("Validating and fixing clipspace structure");
|
||||||
|
|
||||||
|
// Check if clipspace exists
|
||||||
|
if (!ComfyApp.clipspace) {
|
||||||
|
log.debug("ComfyUI clipspace is not available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate clipspace structure
|
||||||
|
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||||
|
log.debug("ComfyUI clipspace has no images");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Current clipspace state:", {
|
||||||
|
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure required indices are set
|
||||||
|
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace selectedIndex to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = 0;
|
||||||
|
log.debug("Fixed clipspace combinedIndex to 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||||
|
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||||
|
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure indices are within bounds
|
||||||
|
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||||
|
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||||
|
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the image at combinedIndex exists and has src
|
||||||
|
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!combinedImg || !combinedImg.src) {
|
||||||
|
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||||
|
// Try to use the first available image
|
||||||
|
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||||
|
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||||
|
ComfyApp.clipspace.combinedIndex = i;
|
||||||
|
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final check - if still no valid image found
|
||||||
|
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||||
|
if (!finalImg || !finalImg.src) {
|
||||||
|
log.error("No valid images found in clipspace after attempting fixes");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Final clipspace structure:", {
|
||||||
|
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||||
|
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||||
|
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||||
|
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||||
|
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||||
|
* @param {any} node - The ComfyUI node to paste to
|
||||||
|
* @returns {boolean} - True if paste was successful, false otherwise
|
||||||
|
*/
|
||||||
|
export function safeClipspacePaste(node: any): boolean {
|
||||||
|
log.debug("Attempting safe clipspace paste");
|
||||||
|
|
||||||
|
if (!validateAndFixClipspace()) {
|
||||||
|
log.debug("Clipspace validation failed, cannot paste");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ComfyApp.pasteFromClipspace(node);
|
||||||
|
log.debug("Successfully called pasteFromClipspace");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error calling pasteFromClipspace:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
DUPLICATE: 'duplicate',
|
DUPLICATE: 'duplicate',
|
||||||
BLEND_MODE: 'blend_mode',
|
BLEND_MODE: 'blend_mode',
|
||||||
OPACITY: 'opacity',
|
OPACITY: 'opacity',
|
||||||
MASK: 'mask',
|
MASK: 'mask',
|
||||||
BRUSH: 'brush',
|
BRUSH: 'brush',
|
||||||
ERASER: 'eraser',
|
ERASER: 'eraser',
|
||||||
@@ -21,16 +21,22 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
SETTINGS: 'settings',
|
SETTINGS: 'settings',
|
||||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||||
CLIPSPACE: 'clipspace',
|
CLIPSPACE: 'clipspace',
|
||||||
|
CROP: 'crop',
|
||||||
|
TRANSFORM: 'transform',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// SVG Icons for LayerForge tools
|
// SVG Icons for LayerForge tools
|
||||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||||
|
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
|
||||||
|
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
|
||||||
|
|
||||||
|
|
||||||
const LAYERFORGE_TOOL_ICONS = {
|
const LAYERFORGE_TOOL_ICONS = {
|
||||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
|
||||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||||
|
|
||||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||||
@@ -72,7 +78,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
|||||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconCache {
|
export interface IconCache {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// @ts-ignore
|
||||||
import { api } from "../../../scripts/api.js";
|
import { api } from "../../../scripts/api.js";
|
||||||
import { createModuleLogger } from "./LoggerUtils.js";
|
import { createModuleLogger } from "./LoggerUtils.js";
|
||||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||||
|
|||||||
@@ -386,3 +386,111 @@ export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageE
|
|||||||
img.src = canvas.toDataURL();
|
img.src = canvas.toDataURL();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scales an image to fit within specified bounds while maintaining aspect ratio
|
||||||
|
* @param image - Image to scale
|
||||||
|
* @param targetWidth - Target width to fit within
|
||||||
|
* @param targetHeight - Target height to fit within
|
||||||
|
* @returns Promise with scaled Image element
|
||||||
|
*/
|
||||||
|
export async function scaleImageToFit(image: HTMLImageElement, targetWidth: number, targetHeight: number): Promise<HTMLImageElement> {
|
||||||
|
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
|
||||||
|
const scaledWidth = Math.max(1, Math.round(image.width * scale));
|
||||||
|
const scaledHeight = Math.max(1, Math.round(image.height * scale));
|
||||||
|
|
||||||
|
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create scaled image context");
|
||||||
|
|
||||||
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const scaledImg = new Image();
|
||||||
|
scaledImg.onload = () => resolve(scaledImg);
|
||||||
|
scaledImg.onerror = reject;
|
||||||
|
scaledImg.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified tensor to image data conversion
|
||||||
|
* Handles both RGB images and grayscale masks
|
||||||
|
* @param tensor - Input tensor data
|
||||||
|
* @param mode - 'rgb' for images or 'grayscale' for masks
|
||||||
|
* @returns ImageData object
|
||||||
|
*/
|
||||||
|
export function tensorToImageData(tensor: any, mode: 'rgb' | 'grayscale' = 'rgb'): ImageData | null {
|
||||||
|
try {
|
||||||
|
const shape = tensor.shape;
|
||||||
|
const height = shape[1];
|
||||||
|
const width = shape[2];
|
||||||
|
const channels = shape[3] || 1; // Default to 1 for masks
|
||||||
|
|
||||||
|
log.debug("Converting tensor:", { shape, channels, mode });
|
||||||
|
|
||||||
|
const imageData = new ImageData(width, height);
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
|
||||||
|
const flatData = tensor.data;
|
||||||
|
const pixelCount = width * height;
|
||||||
|
|
||||||
|
const min = tensor.min_val ?? 0;
|
||||||
|
const max = tensor.max_val ?? 1;
|
||||||
|
const denom = (max - min) || 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < pixelCount; i++) {
|
||||||
|
const pixelIndex = i * 4;
|
||||||
|
const tensorIndex = i * channels;
|
||||||
|
|
||||||
|
let lum: number;
|
||||||
|
if (mode === 'grayscale' || channels === 1) {
|
||||||
|
lum = flatData[tensorIndex];
|
||||||
|
} else {
|
||||||
|
// Compute luminance for RGB
|
||||||
|
const r = flatData[tensorIndex + 0] ?? 0;
|
||||||
|
const g = flatData[tensorIndex + 1] ?? 0;
|
||||||
|
const b = flatData[tensorIndex + 2] ?? 0;
|
||||||
|
lum = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm = (lum - min) / denom;
|
||||||
|
if (!isFinite(norm)) norm = 0;
|
||||||
|
norm = Math.max(0, Math.min(1, norm));
|
||||||
|
const value = Math.round(norm * 255);
|
||||||
|
|
||||||
|
if (mode === 'grayscale') {
|
||||||
|
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
|
||||||
|
data[pixelIndex] = value;
|
||||||
|
data[pixelIndex + 1] = value;
|
||||||
|
data[pixelIndex + 2] = value;
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
} else {
|
||||||
|
// For images: RGB from channels, A = 255
|
||||||
|
for (let c = 0; c < Math.min(3, channels); c++) {
|
||||||
|
const channelValue = flatData[tensorIndex + c];
|
||||||
|
const channelNorm = (channelValue - min) / denom;
|
||||||
|
data[pixelIndex + c] = Math.round(channelNorm * 255);
|
||||||
|
}
|
||||||
|
data[pixelIndex + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData.data.set(data);
|
||||||
|
return imageData;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error converting tensor:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTMLImageElement from ImageData
|
||||||
|
* @param imageData - Input ImageData
|
||||||
|
* @returns Promise with HTMLImageElement
|
||||||
|
*/
|
||||||
|
export async function createImageFromImageData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||||
|
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not create canvas context");
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
return await createImageFromSource(canvas.toDataURL());
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { createModuleLogger } from "./LoggerUtils.js";
|
|||||||
|
|
||||||
const log = createModuleLogger('NotificationUtils');
|
const log = createModuleLogger('NotificationUtils');
|
||||||
|
|
||||||
|
// Store active notifications for deduplication
|
||||||
|
const activeNotifications = new Map<string, { element: HTMLDivElement, timeout: number | null }>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility functions for showing notifications to the user
|
* Utility functions for showing notifications to the user
|
||||||
*/
|
*/
|
||||||
@@ -11,16 +14,62 @@ const log = createModuleLogger('NotificationUtils');
|
|||||||
* @param message - The message to show
|
* @param message - The message to show
|
||||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||||
* @param duration - Duration in milliseconds (default: 3000)
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param type - Type of notification
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
|
||||||
*/
|
*/
|
||||||
export function showNotification(
|
export function showNotification(
|
||||||
message: string,
|
message: string,
|
||||||
backgroundColor: string = "#4a6cd4",
|
backgroundColor: string = "#4a6cd4",
|
||||||
duration: number = 3000,
|
duration: number = 3000,
|
||||||
type: "success" | "error" | "info" | "warning" | "alert" = "info"
|
type: "success" | "error" | "info" | "warning" | "alert" = "info",
|
||||||
|
deduplicate: boolean = false
|
||||||
): void {
|
): void {
|
||||||
// Remove any existing prefix to avoid double prefixing
|
// Remove any existing prefix to avoid double prefixing
|
||||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||||
|
|
||||||
|
// If deduplication is enabled, check if this message already exists
|
||||||
|
if (deduplicate) {
|
||||||
|
const existingNotification = activeNotifications.get(message);
|
||||||
|
if (existingNotification) {
|
||||||
|
log.debug(`Notification already exists, refreshing timer: ${message}`);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (existingNotification.timeout !== null) {
|
||||||
|
clearTimeout(existingNotification.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the progress bar and restart its animation
|
||||||
|
const progressBar = existingNotification.element.querySelector('div[style*="animation"]') as HTMLDivElement;
|
||||||
|
if (progressBar) {
|
||||||
|
// Reset animation
|
||||||
|
progressBar.style.animation = 'none';
|
||||||
|
// Force reflow
|
||||||
|
void progressBar.offsetHeight;
|
||||||
|
// Restart animation
|
||||||
|
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout
|
||||||
|
const newTimeout = window.setTimeout(() => {
|
||||||
|
const notification = existingNotification.element;
|
||||||
|
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||||
|
notification.addEventListener('animationend', () => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
activeNotifications.delete(message);
|
||||||
|
const container = document.getElementById('lf-notification-container');
|
||||||
|
if (container && container.children.length === 0) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
existingNotification.timeout = newTimeout;
|
||||||
|
return; // Don't create a new notification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Type-specific config
|
// Type-specific config
|
||||||
const config = {
|
const config = {
|
||||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||||
@@ -172,6 +221,11 @@ export function showNotification(
|
|||||||
|
|
||||||
let dismissTimeout: number | null = null;
|
let dismissTimeout: number | null = null;
|
||||||
const closeNotification = () => {
|
const closeNotification = () => {
|
||||||
|
// Remove from active notifications map if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
activeNotifications.delete(message);
|
||||||
|
}
|
||||||
|
|
||||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||||
notification.addEventListener('animationend', () => {
|
notification.addEventListener('animationend', () => {
|
||||||
if (notification.parentNode) {
|
if (notification.parentNode) {
|
||||||
@@ -198,46 +252,86 @@ export function showNotification(
|
|||||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||||
};
|
};
|
||||||
|
|
||||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
notification.addEventListener('mouseenter', () => {
|
||||||
notification.addEventListener('mouseleave', startDismissTimer);
|
pauseAndRewindTimer();
|
||||||
|
// Update stored timeout if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
const stored = activeNotifications.get(message);
|
||||||
|
if (stored) {
|
||||||
|
stored.timeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.addEventListener('mouseleave', () => {
|
||||||
|
startDismissTimer();
|
||||||
|
// Update stored timeout if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
const stored = activeNotifications.get(message);
|
||||||
|
if (stored) {
|
||||||
|
stored.timeout = dismissTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
startDismissTimer();
|
startDismissTimer();
|
||||||
|
|
||||||
|
// Store notification if deduplicate is enabled
|
||||||
|
if (deduplicate) {
|
||||||
|
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a success notification
|
* Shows a success notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showSuccessNotification(message: string, duration: number = 3000): void {
|
export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||||
showNotification(message, undefined, duration, "success");
|
showNotification(message, undefined, duration, "success", deduplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows an error notification
|
* Shows an error notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 5000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showErrorNotification(message: string, duration: number = 5000): void {
|
export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void {
|
||||||
showNotification(message, undefined, duration, "error");
|
showNotification(message, undefined, duration, "error", deduplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows an info notification
|
* Shows an info notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showInfoNotification(message: string, duration: number = 3000): void {
|
export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||||
showNotification(message, undefined, duration, "info");
|
showNotification(message, undefined, duration, "info", deduplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a warning notification
|
* Shows a warning notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showWarningNotification(message: string, duration: number = 3000): void {
|
export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||||
showNotification(message, undefined, duration, "warning");
|
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows an alert notification
|
* Shows an alert notification
|
||||||
|
* @param message - The message to show
|
||||||
|
* @param duration - Duration in milliseconds (default: 3000)
|
||||||
|
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||||
*/
|
*/
|
||||||
export function showAlertNotification(message: string, duration: number = 3000): void {
|
export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||||
showNotification(message, undefined, duration, "alert");
|
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,7 +342,7 @@ export function showAllNotificationTypes(message?: string): void {
|
|||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showNotification(notificationMessage, undefined, 3000, type);
|
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||||
}, index * 400); // Stagger the notifications
|
}, index * 400); // Stagger the notifications
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user