mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
60 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 |
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,20 +1,75 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Report an error or unexpected behavior
|
||||
description: 'Report something that is not working correctly'
|
||||
title: "[BUG] "
|
||||
labels: [bug]
|
||||
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
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for reporting a bug!**
|
||||
Please follow these steps to capture all necessary information:
|
||||
|
||||
### ✅ 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:
|
||||
|
||||
## Additional Information (Optional)
|
||||
*The following fields help me debug complex issues but are not required for most bug reports.*
|
||||
### 🔍 Enable Debug Logs (for **full** logs):
|
||||
|
||||
#### 1. Edit `config.js` (Frontend Logs):
|
||||
@@ -46,75 +101,13 @@ body:
|
||||
```
|
||||
|
||||
➡️ **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
|
||||
id: what_happened
|
||||
id: console-errors
|
||||
attributes:
|
||||
label: What Happened?
|
||||
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
|
||||
label: Console Errors
|
||||
description: |
|
||||
If you see red error messages in the browser console (F12), paste them here
|
||||
More info:
|
||||
After enabling DEBUG logs:
|
||||
1. Open Developer Tools → Console.
|
||||
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
||||
@@ -128,11 +121,25 @@ body:
|
||||
- Safari: 🗑 icon or `Cmd+K`.
|
||||
3. Reproduce the issue.
|
||||
4. Copy-paste the **TEXT** logs here (no screenshots).
|
||||
validations:
|
||||
required: true
|
||||
render: javascript
|
||||
|
||||
- type: markdown
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
value: |
|
||||
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
||||
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
||||
label: Logs
|
||||
description: |
|
||||
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] "
|
||||
labels: [documentation]
|
||||
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
|
||||
id: doc_area
|
||||
attributes:
|
||||
label: Area of documentation
|
||||
placeholder: e.g. Getting started, Node API, Deployment guide
|
||||
placeholder: e.g. Key Features, Installation, Controls & Shortcuts
|
||||
validations:
|
||||
required: true
|
||||
- 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]: '
|
||||
labels: ['enhancement']
|
||||
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
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
2
.github/workflows/ComfyUIdownloads.yml
vendored
2
.github/workflows/ComfyUIdownloads.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
max_downloads=0
|
||||
top_node_json="{}"
|
||||
|
||||
for i in {1..20}; do
|
||||
for i in {1..3}; do
|
||||
echo "Pobieranie danych z próby $i..."
|
||||
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json
|
||||
|
||||
|
||||
61
README.md
61
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">
|
||||
</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?
|
||||
|
||||
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
||||
@@ -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.
|
||||
- **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
|
||||
browser's storage footprint low.
|
||||
- **Workflow Integration:** Outputs a final composite **image** and a combined alpha **mask**, ready for any other
|
||||
ComfyUI node.
|
||||
|
||||
- **Inputs**
|
||||
- **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
|
||||
|
||||
### 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
|
||||
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
|
||||
2. Clone this repo into `custom_modules`:
|
||||
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_nodes`:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
git clone https://github.com/Azornes/Comfyui-LayerForge.git
|
||||
@@ -223,18 +240,24 @@ optional feature and requires a model.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
## ⚠️ Known Issues / Compatibility
|
||||
|
||||
### `node_id` not auto-filled → black output
|
||||
#### ○ Incompatibility with Modern Node Design (Vue Nodes)
|
||||
> 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)"**.
|
||||
|
||||
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
||||
As a result, the node may produce a **completely black image** or not work at all.
|
||||
---
|
||||
|
||||
**Workaround:**
|
||||
|
||||
* Search node ID in ComfyUI settings.
|
||||
* In NodesMap check "Enable node ID display"
|
||||
* Manually enter the correct `node_id` (match the ID Node "LayerForge" shown above the node, on the right side).
|
||||
#### ○ `node_id` not auto-filled → black output
|
||||
> In some cases, **ComfyUI doesn’t auto-fill the `node_id`** when adding a node.
|
||||
> 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]
|
||||
> This is a known issue and not yet fixed.
|
||||
@@ -248,6 +271,14 @@ 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
|
||||
|
||||
248
canvas_node.py
248
canvas_node.py
@@ -64,6 +64,8 @@ class BiRefNetConfig(PretrainedConfig):
|
||||
|
||||
def __init__(self, bb_pretrained=False, **kwargs):
|
||||
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)
|
||||
|
||||
|
||||
@@ -179,6 +181,10 @@ class LayerForgeNode:
|
||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||
"node_id": ("STRING", {"default": "0"}),
|
||||
},
|
||||
"optional": {
|
||||
"input_image": ("IMAGE",),
|
||||
"input_mask": ("MASK",),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": ("PROMPT",),
|
||||
"unique_id": ("UNIQUE_ID",),
|
||||
@@ -239,7 +245,7 @@ class LayerForgeNode:
|
||||
|
||||
_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:
|
||||
|
||||
@@ -250,6 +256,81 @@ class LayerForgeNode:
|
||||
|
||||
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
|
||||
|
||||
processed_image = None
|
||||
@@ -433,6 +514,63 @@ class LayerForgeNode:
|
||||
log_info("WebSocket connection closed")
|
||||
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}")
|
||||
async def get_canvas_data(request):
|
||||
try:
|
||||
@@ -619,16 +757,32 @@ class BiRefNetMatting:
|
||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
||||
try:
|
||||
# Try loading with additional configuration to handle compatibility issues
|
||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||
"ZhengPeng7/BiRefNet",
|
||||
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()
|
||||
if torch.cuda.is_available():
|
||||
self.model = self.model.cuda()
|
||||
self.model_cache[model_path] = self.model
|
||||
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:
|
||||
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
|
||||
raise RuntimeError(
|
||||
@@ -758,6 +912,95 @@ class BiRefNetMatting:
|
||||
|
||||
_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")
|
||||
async def matting(request):
|
||||
global _matting_lock
|
||||
@@ -911,4 +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_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||
raise
|
||||
|
||||
|
||||
22
js/Canvas.js
22
js/Canvas.js
@@ -61,9 +61,20 @@ export class Canvas {
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
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.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.pendingInputDataCheck = null;
|
||||
this.inputDataLoaded = false;
|
||||
this.imageCache = new Map();
|
||||
this.requestSaveState = () => { };
|
||||
this.outputAreaShape = null;
|
||||
@@ -363,6 +374,10 @@ export class Canvas {
|
||||
return widget ? widget.value : false;
|
||||
};
|
||||
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()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
@@ -385,6 +400,9 @@ export class Canvas {
|
||||
}
|
||||
};
|
||||
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()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
if (!this.pendingBatchContext) {
|
||||
@@ -425,8 +443,8 @@ export class Canvas {
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
|
||||
516
js/CanvasIO.js
516
js/CanvasIO.js
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
export class CanvasIO {
|
||||
constructor(canvas) {
|
||||
@@ -196,6 +197,25 @@ export class CanvasIO {
|
||||
}
|
||||
async _renderOutputData() {
|
||||
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
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
@@ -247,17 +267,12 @@ export class CanvasIO {
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), 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();
|
||||
});
|
||||
// Use unified tensorToImageData for RGB image
|
||||
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||
if (!imageData)
|
||||
throw new Error("Failed to convert input image tensor");
|
||||
// Create HTMLImageElement from ImageData
|
||||
const image = await createImageFromImageData(imageData);
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
|
||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||
@@ -283,17 +298,10 @@ export class CanvasIO {
|
||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
const imageData = new 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();
|
||||
});
|
||||
const imageData = tensorToImageData(tensor, 'rgb');
|
||||
if (!imageData)
|
||||
throw new Error("Failed to convert tensor to image data");
|
||||
return await createImageFromImageData(imageData);
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
@@ -314,12 +322,26 @@ export class CanvasIO {
|
||||
async initNodeData() {
|
||||
try {
|
||||
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) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
if (this.canvas.node.inputs[0] && 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];
|
||||
if (imageData) {
|
||||
log.debug("Found image data:", imageData);
|
||||
@@ -331,6 +353,10 @@ export class CanvasIO {
|
||||
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) {
|
||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||
@@ -345,6 +371,390 @@ export class CanvasIO {
|
||||
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() {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
@@ -423,51 +833,10 @@ export class CanvasIO {
|
||||
}
|
||||
}
|
||||
convertTensorToImageData(tensor) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return tensorToImageData(tensor, 'rgb');
|
||||
}
|
||||
async createImageFromData(imageData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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();
|
||||
});
|
||||
return createImageFromImageData(imageData);
|
||||
}
|
||||
async processMaskData(maskData) {
|
||||
try {
|
||||
@@ -527,12 +896,7 @@ export class CanvasIO {
|
||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||
const newLayers = [];
|
||||
for (const imageData of result.images) {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
const img = await createImageFromSource(imageData);
|
||||
let processedImage = img;
|
||||
// If there's a custom shape, clip the image to that shape
|
||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||
@@ -559,11 +923,9 @@ export class CanvasIO {
|
||||
}
|
||||
}
|
||||
async clipImageToShape(image, shape) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not create canvas context for clipping"));
|
||||
return;
|
||||
throw new Error("Could not create canvas context for clipping");
|
||||
}
|
||||
// Draw the image first
|
||||
ctx.drawImage(image, 0, 0);
|
||||
@@ -582,10 +944,6 @@ export class CanvasIO {
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// 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();
|
||||
});
|
||||
return await createImageFromSource(canvas.toDataURL());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasInteractions');
|
||||
export class CanvasInteractions {
|
||||
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.interaction = {
|
||||
mode: 'none',
|
||||
panStart: { x: 0, y: 0 },
|
||||
dragStart: { x: 0, y: 0 },
|
||||
transformOrigin: {},
|
||||
transformOrigin: null,
|
||||
resizeHandle: null,
|
||||
resizeAnchor: { x: 0, y: 0 },
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isMetaPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
@@ -22,6 +39,9 @@ export class CanvasInteractions {
|
||||
keyMovementInProgress: false,
|
||||
canvasResizeRect: null,
|
||||
canvasMoveRect: null,
|
||||
outputAreaTransformHandle: null,
|
||||
outputAreaTransformAnchor: { x: 0, y: 0 },
|
||||
hoveringGrabIcon: false,
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
@@ -32,18 +52,29 @@ export class CanvasInteractions {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
performZoomOperation(worldCoords, zoomFactor) {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
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 newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
this.canvas.viewport.zoom = newZoom;
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / 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?.();
|
||||
}
|
||||
renderAndSave(shouldSave = false) {
|
||||
@@ -64,29 +95,43 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
setupEventListeners() {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
|
||||
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseleave', (e) => {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||
window.addEventListener('blur', this.onBlur);
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
|
||||
document.addEventListener('keydown', this.onKeyDown, { capture: true });
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
|
||||
this.canvas.canvas.addEventListener('drop', this.onDrop);
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||
}
|
||||
teardownEventListeners() {
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
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
|
||||
@@ -111,6 +156,29 @@ export class CanvasInteractions {
|
||||
}
|
||||
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() {
|
||||
this.interaction.mode = 'none';
|
||||
this.interaction.resizeHandle = null;
|
||||
@@ -119,13 +187,33 @@ export class CanvasInteractions {
|
||||
this.interaction.canvasMoveRect = null;
|
||||
this.interaction.hasClonedInDrag = false;
|
||||
this.interaction.transformingLayer = null;
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
handleMouseDown(e) {
|
||||
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 mods = this.getModifierState(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -135,11 +223,11 @@ export class CanvasInteractions {
|
||||
}
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
if (mods.shift && mods.ctrl) {
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
@@ -163,7 +251,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) { // Środkowy przycisk
|
||||
if (e.button === 1) { // Środkowy przycisk
|
||||
this.startPanning(e);
|
||||
return;
|
||||
}
|
||||
@@ -173,13 +261,21 @@ export class CanvasInteractions {
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
|
||||
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);
|
||||
if (clickedLayerResult) {
|
||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||
return;
|
||||
}
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
this.startPanning(e, true); // clearSelection = true
|
||||
}
|
||||
handleMouseMove(e) {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
@@ -199,7 +295,7 @@ export class CanvasInteractions {
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
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;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
@@ -219,8 +315,27 @@ export class CanvasInteractions {
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
case 'transformingOutputArea':
|
||||
if (this.interaction.outputAreaTransformHandle) {
|
||||
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
|
||||
}
|
||||
else {
|
||||
this.updateOutputAreaTransformCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
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);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||
@@ -232,6 +347,7 @@ export class CanvasInteractions {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
// Render only once after drawing is complete
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -241,6 +357,10 @@ export class CanvasInteractions {
|
||||
if (this.interaction.mode === 'movingCanvas') {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
|
||||
this.finalizeOutputAreaTransform();
|
||||
return;
|
||||
}
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
@@ -315,23 +435,33 @@ export class CanvasInteractions {
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
else {
|
||||
// Layer transformation when layers are selected
|
||||
// Check if mouse is over any selected layer
|
||||
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();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
}
|
||||
handleLayerWheelTransformation(e) {
|
||||
const mods = this.getModifierState(e);
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
if (mods.shift) {
|
||||
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||
}
|
||||
else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -376,7 +506,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||
const gridSize = 64;
|
||||
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
let targetHeight;
|
||||
if (direction > 0) {
|
||||
@@ -399,12 +529,24 @@ export class CanvasInteractions {
|
||||
return targetHeight / oldHeight;
|
||||
}
|
||||
handleKeyDown(e) {
|
||||
// Always track modifier keys regardless of focus
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta')
|
||||
this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
if (e.key === 'Alt')
|
||||
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();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
@@ -418,11 +560,12 @@ export class CanvasInteractions {
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'z':
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
this.canvas.redo();
|
||||
}
|
||||
else {
|
||||
@@ -437,6 +580,17 @@ export class CanvasInteractions {
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
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:
|
||||
handled = false;
|
||||
break;
|
||||
@@ -449,7 +603,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const step = mods.shift ? 10 : 1;
|
||||
let needsRender = false;
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
@@ -485,6 +639,8 @@ export class CanvasInteractions {
|
||||
handleKeyUp(e) {
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Meta')
|
||||
this.interaction.isMetaPressed = false;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt')
|
||||
@@ -504,6 +660,7 @@ export class CanvasInteractions {
|
||||
handleBlur() {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isMetaPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
@@ -525,6 +682,16 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (transformTarget) {
|
||||
const handleName = transformTarget.handle;
|
||||
@@ -572,15 +739,16 @@ export class CanvasInteractions {
|
||||
}
|
||||
prepareForDrag(layer, worldCoords) {
|
||||
// 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);
|
||||
if (index === -1) {
|
||||
// Ctrl-clicking unselected layer: add to selection
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
}
|
||||
else {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
|
||||
// User can use right-click in layers panel to deselect individual layers
|
||||
}
|
||||
else {
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
@@ -590,10 +758,9 @@ export class CanvasInteractions {
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
}
|
||||
startPanningOrClearSelection(e) {
|
||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
startPanning(e, clearSelection = true) {
|
||||
// Unified panning method - can optionally clear selection
|
||||
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
@@ -642,19 +809,16 @@ export class CanvasInteractions {
|
||||
this.canvas.render();
|
||||
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) {
|
||||
const dx = e.clientX - this.interaction.panStart.x;
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
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.onViewportChange?.();
|
||||
}
|
||||
@@ -709,7 +873,7 @@ export class CanvasInteractions {
|
||||
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||
}
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
||||
if (!o)
|
||||
return;
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
@@ -856,7 +1020,7 @@ export class CanvasInteractions {
|
||||
if (!layer)
|
||||
return;
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
|
||||
if (!o)
|
||||
return;
|
||||
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);
|
||||
@@ -1021,10 +1185,13 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
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 ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas ||
|
||||
document.activeElement === document.body;
|
||||
document.activeElement === this.canvas.canvas;
|
||||
if (!shouldHandle) {
|
||||
log.debug("Paste event ignored - not focused on canvas");
|
||||
return;
|
||||
@@ -1065,4 +1232,168 @@ export class CanvasInteractions {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export class CanvasLayers {
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0);
|
||||
const newImage = new Image();
|
||||
newImage.crossOrigin = 'anonymous';
|
||||
newImage.src = tempCanvas.toDataURL();
|
||||
layer.image = newImage;
|
||||
}
|
||||
@@ -158,6 +159,7 @@ export class CanvasLayers {
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (!this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [];
|
||||
@@ -196,6 +198,117 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Automatically adjust output area to fit selected layers
|
||||
* Calculates precise bounding box for all selected layers including rotation and crop mode support
|
||||
*/
|
||||
autoAdjustOutputToSelection() {
|
||||
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Calculate bounding box of selected layers
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
selectedLayers.forEach((layer) => {
|
||||
// For crop mode layers, use the visible crop bounds
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const cropWidth = layer.cropBounds.width * layerScaleX;
|
||||
const cropHeight = layer.cropBounds.height * layerScaleY;
|
||||
const effectiveCropX = layer.flipH
|
||||
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
|
||||
: layer.cropBounds.x;
|
||||
const effectiveCropY = layer.flipV
|
||||
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
|
||||
: layer.cropBounds.y;
|
||||
const cropOffsetX = effectiveCropX * layerScaleX;
|
||||
const cropOffsetY = effectiveCropY * layerScaleY;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
// Calculate corners of the crop rectangle
|
||||
const corners = [
|
||||
{ x: cropOffsetX, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
|
||||
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
|
||||
];
|
||||
corners.forEach(p => {
|
||||
// Transform to layer space (centered)
|
||||
const localX = p.x - layer.width / 2;
|
||||
const localY = p.y - layer.height / 2;
|
||||
// Apply rotation
|
||||
const worldX = centerX + (localX * cos - localY * sin);
|
||||
const worldY = centerY + (localX * sin + localY * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// For normal layers, use the full layer bounds
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Calculate new dimensions without padding for precise fit
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
log.error("Cannot calculate valid output area dimensions");
|
||||
return false;
|
||||
}
|
||||
// Update output area bounds
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
// Update canvas dimensions
|
||||
this.canvas.width = newWidth;
|
||||
this.canvas.height = newHeight;
|
||||
this.canvas.maskTool.resize(newWidth, newHeight);
|
||||
this.canvas.canvas.width = newWidth;
|
||||
this.canvas.canvas.height = newHeight;
|
||||
// Reset extensions
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.canvas.outputAreaExtensionEnabled = false;
|
||||
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
// Update original canvas size and position
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
|
||||
// Save state and render
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
|
||||
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
pasteLayers() {
|
||||
if (this.internalClipboard.length === 0)
|
||||
return;
|
||||
@@ -742,6 +855,7 @@ export class CanvasLayers {
|
||||
}
|
||||
// Convert canvas to image
|
||||
const processedImage = new Image();
|
||||
processedImage.crossOrigin = 'anonymous';
|
||||
processedImage.src = processedCanvas.toDataURL();
|
||||
return processedImage;
|
||||
}
|
||||
@@ -986,8 +1100,8 @@ export class CanvasLayers {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
// Don't set canvas.width/height - the render loop will handle display size
|
||||
// this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
|
||||
this.canvas.render();
|
||||
if (saveHistory) {
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
@@ -1611,6 +1725,7 @@ export class CanvasLayers {
|
||||
tempCtx.translate(-minX, -minY);
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
const fusedImage = new Image();
|
||||
fusedImage.crossOrigin = 'anonymous';
|
||||
fusedImage.src = tempCanvas.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
fusedImage.onload = resolve;
|
||||
|
||||
@@ -103,6 +103,7 @@ export class CanvasLayersPanel {
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
@@ -115,12 +116,33 @@ export class CanvasLayersPanel {
|
||||
this.layersContainer = this.container.querySelector('#layers-container');
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
this.setupMasterVisibilityToggle();
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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');
|
||||
@@ -142,6 +164,67 @@ export class CanvasLayersPanel {
|
||||
// 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() {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
@@ -158,6 +241,8 @@ export class CanvasLayersPanel {
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
if (this._updateMasterVisibilityToggle)
|
||||
this._updateMasterVisibilityToggle();
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
createLayerElement(layer, index) {
|
||||
@@ -264,6 +349,8 @@ export class CanvasLayersPanel {
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
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}`);
|
||||
}
|
||||
startEditingLayerName(nameElement, layer) {
|
||||
|
||||
@@ -7,6 +7,9 @@ export class CanvasRenderer {
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
/**
|
||||
* Helper function to draw text with background at world coordinates
|
||||
@@ -102,10 +105,12 @@ export class CanvasRenderer {
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
}
|
||||
else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
@@ -136,12 +141,17 @@ export class CanvasRenderer {
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
// Draw grab icons for selected layers when hovering
|
||||
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
|
||||
this.drawGrabIcons(ctx);
|
||||
}
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||
this.renderLayerInfo(ctx);
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
@@ -158,6 +168,11 @@ export class CanvasRenderer {
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
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
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||
@@ -583,4 +598,328 @@ export class CanvasRenderer {
|
||||
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.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
|
||||
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||
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.`);
|
||||
// Don't return false - allow empty canvas to be valid
|
||||
}
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
@@ -200,6 +200,7 @@ export class CanvasState {
|
||||
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
@@ -216,6 +217,7 @@ export class CanvasState {
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
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) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.canvas.render();
|
||||
}
|
||||
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();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
|
||||
454
js/CanvasView.js
454
js/CanvasView.js
@@ -8,7 +8,7 @@ import { clearAllCanvasStates } from "./db.js";
|
||||
import { ImageCache } from "./ImageCache.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.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 { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||
const log = createModuleLogger('Canvas_view');
|
||||
@@ -213,88 +213,32 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$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", {
|
||||
textContent: "Output Area Size",
|
||||
title: "Set the size of the output area",
|
||||
title: "Transform output area - drag handles to resize",
|
||||
onclick: () => {
|
||||
const dialog = $el("div.painter-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
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);
|
||||
};
|
||||
// Activate output area transform mode
|
||||
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
@@ -399,11 +343,38 @@ async function createCanvasWidget(node, widget, app) {
|
||||
const button = e.target.closest('.matting-button');
|
||||
if (button.classList.contains('loading'))
|
||||
return;
|
||||
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);
|
||||
try {
|
||||
}
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||
throw new Error("Please select exactly one image layer for matting.");
|
||||
}
|
||||
@@ -419,7 +390,20 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||
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);
|
||||
}
|
||||
@@ -439,11 +423,16 @@ async function createCanvasWidget(node, widget, app) {
|
||||
catch (error) {
|
||||
log.error("Matting error:", error);
|
||||
const errorMessage = error.message || "An unknown error occurred.";
|
||||
if (!errorMessage.includes("Network Connection Error") &&
|
||||
!errorMessage.includes("Matting Model Error") &&
|
||||
!errorMessage.includes("Dependency Not Found")) {
|
||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
button.classList.remove('loading');
|
||||
if (button.contains(spinner)) {
|
||||
const spinner = button.querySelector('.matting-spinner');
|
||||
if (spinner && button.contains(spinner)) {
|
||||
button.removeChild(spinner);
|
||||
}
|
||||
}
|
||||
@@ -554,6 +543,25 @@ async function createCanvasWidget(node, widget, app) {
|
||||
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("label", { for: "brush-size-slider", textContent: "Size:" }),
|
||||
$el("input", {
|
||||
@@ -876,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (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', () => {
|
||||
canvasContainer.classList.add('has-focus');
|
||||
});
|
||||
@@ -892,7 +906,9 @@ async function createCanvasWidget(node, widget, app) {
|
||||
height: "100%"
|
||||
}
|
||||
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||
if (node.addDOMWidget) {
|
||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||
}
|
||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`);
|
||||
let backdrop = null;
|
||||
let originalParent = null;
|
||||
@@ -981,7 +997,11 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (!window.canvasExecutionStates) {
|
||||
window.canvasExecutionStates = new Map();
|
||||
}
|
||||
node.canvasWidget = canvas;
|
||||
// Store the entire widget object, not just the canvas
|
||||
node.canvasWidget = {
|
||||
canvas: canvas,
|
||||
panel: controlPanel
|
||||
};
|
||||
setTimeout(() => {
|
||||
canvas.loadInitialState();
|
||||
if (canvas.canvasLayersPanel) {
|
||||
@@ -998,7 +1018,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (canvas && canvas.setPreviewVisibility) {
|
||||
canvas.setPreviewVisibility(value);
|
||||
}
|
||||
if (node.graph && node.graph.canvas) {
|
||||
if (node.graph && node.graph.canvas && node.setDirtyCanvas) {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
@@ -1024,13 +1044,20 @@ app.registerExtension({
|
||||
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
|
||||
const sendPromises = [];
|
||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
}
|
||||
else {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||
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 {
|
||||
@@ -1049,6 +1076,8 @@ app.registerExtension({
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||
// Map to track pending copy sources across node ID changes
|
||||
const pendingCopySources = new Map();
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
@@ -1077,9 +1106,181 @@ app.registerExtension({
|
||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||
canvasNodeInstances.set(this.id, canvasWidget);
|
||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||
setTimeout(() => {
|
||||
this.setDirtyCanvas(true, true);
|
||||
// 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(() => {
|
||||
if (this.inputs && this.inputs.length > 0) {
|
||||
// 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;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
@@ -1109,6 +1310,47 @@ app.registerExtension({
|
||||
}
|
||||
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;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
@@ -1192,8 +1434,8 @@ app.registerExtension({
|
||||
callback: async () => {
|
||||
try {
|
||||
log.info("Opening LayerForge canvas in MaskEditor");
|
||||
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
|
||||
await self.canvasWidget.startMaskEditor(null, true);
|
||||
if (self.canvasWidget && self.canvasWidget.canvas) {
|
||||
await self.canvasWidget.canvas.startMaskEditor(null, true);
|
||||
}
|
||||
else {
|
||||
log.error("Canvas widget not available");
|
||||
@@ -1210,9 +1452,9 @@ app.registerExtension({
|
||||
content: "Open Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1228,9 +1470,9 @@ app.registerExtension({
|
||||
content: "Open Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1246,9 +1488,9 @@ app.registerExtension({
|
||||
content: "Copy Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
@@ -1265,9 +1507,9 @@ app.registerExtension({
|
||||
content: "Copy Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
@@ -1284,9 +1526,9 @@ app.registerExtension({
|
||||
content: "Save Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1307,9 +1549,9 @@ app.registerExtension({
|
||||
content: "Save Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -424,7 +424,6 @@ export class MaskEditorIntegration {
|
||||
boundsPos: { x: bounds.x, y: bounds.y },
|
||||
maskSize: { width: bounds.width, height: bounds.height }
|
||||
});
|
||||
// Use the chunk system instead of direct canvas manipulation
|
||||
this.maskTool.setMask(maskAsImage);
|
||||
// Update node preview using PreviewUtils
|
||||
await updateNodePreview(this.canvas, this.node, true);
|
||||
|
||||
242
js/MaskTool.js
242
js/MaskTool.js
@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('Mask_tool');
|
||||
export class MaskTool {
|
||||
constructor(canvasInstance, callbacks = {}) {
|
||||
// Track strokes during drawing for efficient overlay updates
|
||||
this.currentStrokePoints = [];
|
||||
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
||||
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
||||
this.canvasInstance = canvasInstance;
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
// Initialize stroke tracking for overlay drawing
|
||||
this.currentStrokePoints = [];
|
||||
// Initialize chunked mask system
|
||||
this.maskChunks = new Map();
|
||||
this.chunkSize = 512;
|
||||
@@ -28,8 +32,9 @@ export class MaskTool {
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this._brushStrength = 0.5;
|
||||
this._brushHardness = 0.5;
|
||||
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Getters for brush properties
|
||||
get brushStrength() {
|
||||
return this._brushStrength;
|
||||
}
|
||||
get brushHardness() {
|
||||
return this._brushHardness;
|
||||
}
|
||||
get previewOpacity() {
|
||||
return this._previewOpacity;
|
||||
}
|
||||
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() {
|
||||
// Initialize chunked system
|
||||
@@ -671,16 +695,17 @@ export class MaskTool {
|
||||
this.brushSize = Math.max(1, size);
|
||||
}
|
||||
setBrushStrength(strength) {
|
||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
||||
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||
}
|
||||
handleMouseDown(worldCoords, viewCoords) {
|
||||
if (!this.isActive)
|
||||
return;
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
// Activate chunks around the drawing position for performance
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
this.draw(worldCoords);
|
||||
// Initialize stroke tracking for live preview
|
||||
this.currentStrokePoints = [worldCoords];
|
||||
// Clear any previous stroke overlay
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
this.clearPreview();
|
||||
}
|
||||
handleMouseMove(worldCoords, viewCoords) {
|
||||
@@ -689,14 +714,69 @@ export class MaskTool {
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing)
|
||||
return;
|
||||
// Dynamically update active chunks as user moves while drawing
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
this.draw(worldCoords);
|
||||
// Add point to stroke tracking
|
||||
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.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() {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
// Clear overlay canvases when mouse leaves
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
}
|
||||
handleMouseEnter() {
|
||||
this.previewVisible = true;
|
||||
@@ -706,10 +786,15 @@ export class MaskTool {
|
||||
return;
|
||||
if (this.isDrawing) {
|
||||
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.currentDrawingChunk = null;
|
||||
// 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.drawBrushPreview(viewCoords);
|
||||
}
|
||||
@@ -724,6 +809,38 @@ export class MaskTool {
|
||||
// This prevents unnecessary recomposition during drawing
|
||||
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
|
||||
*/
|
||||
@@ -767,13 +884,13 @@ export class MaskTool {
|
||||
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
||||
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
if (this.brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
if (this._brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||
}
|
||||
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);
|
||||
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)`);
|
||||
chunk.ctx.strokeStyle = gradient;
|
||||
}
|
||||
@@ -805,28 +922,17 @@ export class MaskTool {
|
||||
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
|
||||
* During drawing, only updates the affected active chunks for performance
|
||||
* Updates active canvas when drawing affects chunks
|
||||
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||
*/
|
||||
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
||||
// Calculate which chunks were affected by this drawing operation
|
||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
||||
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 {
|
||||
// This method is now simplified - we only update after drawing is complete
|
||||
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||
if (!this.isDrawing) {
|
||||
// Not drawing - do full update to show all chunks
|
||||
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
|
||||
@@ -903,18 +1009,12 @@ export class MaskTool {
|
||||
}
|
||||
drawBrushPreview(viewCoords) {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
return;
|
||||
}
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
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();
|
||||
// Use overlay canvas instead of preview canvas for brush cursor
|
||||
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||
}
|
||||
clearPreview() {
|
||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||
@@ -1252,6 +1352,23 @@ export class MaskTool {
|
||||
this.canvasInstance.render();
|
||||
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() {
|
||||
// Return the current active mask canvas which shows all chunks
|
||||
// Only update if there are pending changes to avoid unnecessary redraws
|
||||
@@ -1345,13 +1462,44 @@ export class MaskTool {
|
||||
this.isOverlayVisible = !this.isOverlayVisible;
|
||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||
}
|
||||
setMask(image) {
|
||||
// Clear existing mask chunks in the output area first
|
||||
setMask(image, isFromInputMask = false) {
|
||||
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) {
|
||||
// For INPUT MASK - process black background to transparent using luminance
|
||||
// Center like input images
|
||||
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 new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||
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
|
||||
|
||||
@@ -242,7 +242,32 @@ async function handleSAMDetectorResult(node, resultImage) {
|
||||
// Try to reload the image with a fresh request
|
||||
log.debug("Attempting to reload SAM result image");
|
||||
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
|
||||
if (originalSrc.startsWith('data:')) {
|
||||
log.debug("Image is a data URL, skipping reload with parameters");
|
||||
// For data URLs, just ensure the image is loaded
|
||||
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resultImage.width = img.width;
|
||||
resultImage.height = img.height;
|
||||
log.debug("Data URL image loaded successfully", {
|
||||
width: img.width,
|
||||
height: img.height
|
||||
});
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
log.error("Failed to load data URL image", error);
|
||||
reject(error);
|
||||
};
|
||||
img.src = originalSrc; // Use original src without modifications
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
// For regular URLs, add cache-busting parameter
|
||||
const url = new URL(originalSrc);
|
||||
url.searchParams.set('_t', Date.now().toString());
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -273,6 +298,7 @@ async function handleSAMDetectorResult(node, resultImage) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to load image from SAM Detector.", error);
|
||||
showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
|
||||
@@ -290,27 +316,37 @@ async function handleSAMDetectorResult(node, resultImage) {
|
||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||
log.debug("Checking canvas and maskTool availability", {
|
||||
hasCanvas: !!canvas,
|
||||
hasCanvasProperty: !!canvas.canvas,
|
||||
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||
hasMaskTool: !!canvas.maskTool,
|
||||
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||
maskToolType: typeof canvas.maskTool,
|
||||
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||
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:", {
|
||||
hasCanvas: !!canvas,
|
||||
hasActualCanvas: !!actualCanvas,
|
||||
canvasConstructor: canvas.constructor.name,
|
||||
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||
canvasKeys: Object.keys(canvas),
|
||||
maskToolValue: canvas.maskTool
|
||||
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||
maskToolValue: maskTool
|
||||
});
|
||||
throw new Error("Mask tool not available or not initialized");
|
||||
}
|
||||
log.debug("Applying SAM mask to canvas using addMask method");
|
||||
// Use the addMask method which overlays on existing mask without clearing it
|
||||
canvas.maskTool.addMask(maskAsImage);
|
||||
log.debug("Applying SAM mask to canvas using setMask method");
|
||||
// Use the setMask method which clears existing mask and sets new one
|
||||
maskTool.setMask(maskAsImage);
|
||||
// Update canvas and save state (same as MaskEditorIntegration)
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
actualCanvas.render();
|
||||
actualCanvas.saveState();
|
||||
// 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");
|
||||
// Show success notification
|
||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||
@@ -340,13 +376,20 @@ export function setupSAMDetectorHook(node, options) {
|
||||
try {
|
||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||
// Automatically send canvas to clipspace and start monitoring
|
||||
if (node.canvasWidget && node.canvasWidget.canvas) {
|
||||
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
||||
// Use ImageUploadUtils to upload canvas
|
||||
if (node.canvasWidget) {
|
||||
const canvasWidget = node.canvasWidget;
|
||||
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, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
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
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
node.clipspaceImg = uploadResult.imageElement;
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -23,6 +23,85 @@
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
// @ts-ignore
|
||||
@@ -18,6 +18,7 @@ export class ClipboardManager {
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
showInfoNotification("Layers pasted from internal clipboard");
|
||||
return true;
|
||||
}
|
||||
if (preference === 'clipspace') {
|
||||
@@ -27,9 +28,20 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
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");
|
||||
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');
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
@@ -51,6 +63,7 @@ export class ClipboardManager {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from Clipspace");
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
@@ -96,6 +109,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from file path");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -131,6 +145,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from system clipboard");
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result;
|
||||
@@ -173,7 +188,17 @@ export class ClipboardManager {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
if (text) {
|
||||
// Check if it's a data URI (base64 encoded image)
|
||||
if (this.isDataURI(text)) {
|
||||
log.info("Found data URI in clipboard");
|
||||
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) {
|
||||
@@ -181,6 +206,7 @@ export class ClipboardManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
@@ -188,6 +214,48 @@ export class ClipboardManager {
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
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
|
||||
* @param {string} text - The text to validate
|
||||
@@ -252,10 +320,12 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from URL");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
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;
|
||||
@@ -313,6 +383,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from selected file");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
|
||||
@@ -314,3 +314,102 @@ export function canvasToMaskImage(canvas) {
|
||||
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";
|
||||
const log = createModuleLogger('NotificationUtils');
|
||||
// Store active notifications for deduplication
|
||||
const activeNotifications = new Map();
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
@@ -8,10 +10,50 @@ const log = createModuleLogger('NotificationUtils');
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @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
|
||||
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
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
@@ -148,6 +190,10 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
||||
body.classList.add('notification-scrollbar');
|
||||
let dismissTimeout = null;
|
||||
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.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
@@ -171,40 +217,77 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
||||
progressBar.style.transform = computedStyle.transform;
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
notification.addEventListener('mouseenter', () => {
|
||||
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();
|
||||
// Store notification if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||
}
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "success", deduplicate);
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
export function showErrorNotification(message, duration = 5000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "error", deduplicate);
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
export function showInfoNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "info", deduplicate);
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
export function showWarningNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
export function showAlertNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows a sequence of all notification types for debugging purposes.
|
||||
@@ -214,7 +297,7 @@ export function showAllNotificationTypes(message) {
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
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."
|
||||
version = "1.5.4"
|
||||
version = "1.5.11"
|
||||
license = { text = "MIT License" }
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
|
||||
@@ -84,11 +84,18 @@ export class Canvas {
|
||||
node: ComfyNode;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
offscreenCtx: CanvasRenderingContext2D | null;
|
||||
overlayCanvas: HTMLCanvasElement;
|
||||
overlayCtx: CanvasRenderingContext2D;
|
||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||
onViewportChange: (() => void) | null;
|
||||
onStateChange: (() => void) | undefined;
|
||||
pendingBatchContext: any;
|
||||
pendingDataCheck: number | null;
|
||||
pendingInputDataCheck: number | null;
|
||||
inputDataLoaded: boolean;
|
||||
lastLoadedImageSrc?: string;
|
||||
lastLoadedLinkId?: number;
|
||||
lastLoadedMaskLinkId?: number;
|
||||
previewVisible: boolean;
|
||||
requestSaveState: () => void;
|
||||
viewport: Viewport;
|
||||
@@ -122,10 +129,22 @@ export class Canvas {
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
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.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.pendingInputDataCheck = null;
|
||||
this.inputDataLoaded = false;
|
||||
this.imageCache = new Map();
|
||||
|
||||
this.requestSaveState = () => {};
|
||||
@@ -471,6 +490,11 @@ export class Canvas {
|
||||
};
|
||||
|
||||
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()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
@@ -494,6 +518,10 @@ export class Canvas {
|
||||
};
|
||||
|
||||
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()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
|
||||
@@ -550,8 +578,8 @@ export class Canvas {
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
|
||||
586
src/CanvasIO.ts
586
src/CanvasIO.ts
@@ -2,6 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Shape } from './types';
|
||||
|
||||
@@ -217,6 +218,29 @@ export class CanvasIO {
|
||||
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
||||
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
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
@@ -282,22 +306,12 @@ export class CanvasIO {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
// Use unified tensorToImageData for RGB image
|
||||
const imageData = tensorToImageData(inputImage, 'rgb');
|
||||
if (!imageData) throw new Error("Failed to convert input image tensor");
|
||||
|
||||
const imgData = new ImageData(
|
||||
new Uint8ClampedArray(inputImage.data),
|
||||
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();
|
||||
});
|
||||
// Create HTMLImageElement from ImageData
|
||||
const image = await createImageFromImageData(imageData);
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const scale = Math.min(
|
||||
@@ -333,23 +347,10 @@ export class CanvasIO {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
const imageData = tensorToImageData(tensor, 'rgb');
|
||||
if (!imageData) throw new Error("Failed to convert tensor to image data");
|
||||
|
||||
const imageData = new 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();
|
||||
});
|
||||
return await createImageFromImageData(imageData);
|
||||
} catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
throw error;
|
||||
@@ -372,6 +373,16 @@ export class CanvasIO {
|
||||
try {
|
||||
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) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
@@ -379,6 +390,14 @@ export class CanvasIO {
|
||||
|
||||
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;
|
||||
|
||||
// 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];
|
||||
|
||||
if (imageData) {
|
||||
@@ -389,6 +408,9 @@ export class CanvasIO {
|
||||
log.debug("Image data not available yet");
|
||||
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) {
|
||||
@@ -407,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 {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
@@ -499,59 +954,11 @@ export class CanvasIO {
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor: any): ImageData | null {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return tensorToImageData(tensor, 'rgb');
|
||||
}
|
||||
|
||||
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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();
|
||||
});
|
||||
return createImageFromImageData(imageData);
|
||||
}
|
||||
|
||||
async processMaskData(maskData: any): Promise<void> {
|
||||
@@ -618,12 +1025,7 @@ export class CanvasIO {
|
||||
const newLayers: (Layer | null)[] = [];
|
||||
|
||||
for (const imageData of result.images) {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
const img = await createImageFromSource(imageData);
|
||||
|
||||
let processedImage = img;
|
||||
|
||||
@@ -652,11 +1054,9 @@ export class CanvasIO {
|
||||
}
|
||||
|
||||
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not create canvas context for clipping"));
|
||||
return;
|
||||
throw new Error("Could not create canvas context for clipping");
|
||||
}
|
||||
|
||||
// Draw the image first
|
||||
@@ -679,10 +1079,6 @@ export class CanvasIO {
|
||||
ctx.fill();
|
||||
|
||||
// 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();
|
||||
});
|
||||
return await createImageFromSource(canvas.toDataURL());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,36 @@ interface MouseCoordinates {
|
||||
view: Point;
|
||||
}
|
||||
|
||||
interface ModifierState {
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
interface TransformOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
cropBounds?: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape' | 'transformingOutputArea';
|
||||
panStart: Point;
|
||||
dragStart: Point;
|
||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
||||
transformOrigin: TransformOrigin | null;
|
||||
resizeHandle: string | null;
|
||||
resizeAnchor: Point;
|
||||
canvasResizeStart: Point;
|
||||
isCtrlPressed: boolean;
|
||||
isMetaPressed: boolean;
|
||||
isAltPressed: boolean;
|
||||
isShiftPressed: boolean;
|
||||
isSPressed: boolean;
|
||||
@@ -28,6 +49,9 @@ interface InteractionState {
|
||||
keyMovementInProgress: boolean;
|
||||
canvasResizeRect: { x: number, y: number, width: number, height: number } | null;
|
||||
canvasMoveRect: { x: number, y: number, width: number, height: number } | null;
|
||||
outputAreaTransformHandle: string | null;
|
||||
outputAreaTransformAnchor: Point;
|
||||
hoveringGrabIcon: boolean;
|
||||
}
|
||||
|
||||
export class CanvasInteractions {
|
||||
@@ -35,17 +59,35 @@ export class CanvasInteractions {
|
||||
public interaction: InteractionState;
|
||||
private originalLayerPositions: Map<Layer, Point>;
|
||||
|
||||
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||
private onMouseDown = (e: MouseEvent) => this.handleMouseDown(e);
|
||||
private onMouseMove = (e: MouseEvent) => this.handleMouseMove(e);
|
||||
private onMouseUp = (e: MouseEvent) => this.handleMouseUp(e);
|
||||
private onMouseEnter = (e: MouseEvent) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
|
||||
private onMouseLeave = (e: MouseEvent) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
|
||||
private onWheel = (e: WheelEvent) => this.handleWheel(e);
|
||||
private onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
|
||||
private onKeyUp = (e: KeyboardEvent) => this.handleKeyUp(e);
|
||||
private onDragOver = (e: DragEvent) => this.handleDragOver(e);
|
||||
private onDragEnter = (e: DragEvent) => this.handleDragEnter(e);
|
||||
private onDragLeave = (e: DragEvent) => this.handleDragLeave(e);
|
||||
private onDrop = (e: DragEvent) => { this.handleDrop(e); };
|
||||
private onContextMenu = (e: MouseEvent) => this.handleContextMenu(e);
|
||||
private onBlur = () => this.handleBlur();
|
||||
private onPaste = (e: ClipboardEvent) => this.handlePasteEvent(e);
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.interaction = {
|
||||
mode: 'none',
|
||||
panStart: { x: 0, y: 0 },
|
||||
dragStart: { x: 0, y: 0 },
|
||||
transformOrigin: {},
|
||||
transformOrigin: null,
|
||||
resizeHandle: null,
|
||||
resizeAnchor: { x: 0, y: 0 },
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isMetaPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
@@ -55,6 +97,9 @@ export class CanvasInteractions {
|
||||
keyMovementInProgress: false,
|
||||
canvasResizeRect: null,
|
||||
canvasMoveRect: null,
|
||||
outputAreaTransformHandle: null,
|
||||
outputAreaTransformAnchor: { x: 0, y: 0 },
|
||||
hoveringGrabIcon: false,
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
@@ -68,13 +113,21 @@ export class CanvasInteractions {
|
||||
};
|
||||
}
|
||||
|
||||
private getModifierState(e?: MouseEvent | WheelEvent | KeyboardEvent): ModifierState {
|
||||
return {
|
||||
ctrl: this.interaction.isCtrlPressed || (e as any)?.ctrlKey || false,
|
||||
shift: this.interaction.isShiftPressed || (e as any)?.shiftKey || false,
|
||||
alt: this.interaction.isAltPressed || (e as any)?.altKey || false,
|
||||
meta: this.interaction.isMetaPressed || (e as any)?.metaKey || false,
|
||||
};
|
||||
}
|
||||
|
||||
private preventEventDefaults(e: Event): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
@@ -84,6 +137,11 @@ export class CanvasInteractions {
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / 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?.();
|
||||
}
|
||||
|
||||
@@ -106,34 +164,55 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
setupEventListeners(): void {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp as EventListener);
|
||||
this.canvas.canvas.addEventListener('wheel', this.onWheel as EventListener, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.onKeyDown as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.onKeyUp as EventListener);
|
||||
|
||||
// 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 as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
|
||||
document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
|
||||
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragover', this.onDragOver as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave as EventListener);
|
||||
this.canvas.canvas.addEventListener('drop', this.onDrop as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||
}
|
||||
|
||||
teardownEventListeners(): void {
|
||||
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp as EventListener);
|
||||
this.canvas.canvas.removeEventListener('wheel', this.onWheel as EventListener);
|
||||
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
|
||||
|
||||
// Remove document-level capture listener
|
||||
document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
|
||||
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('dragover', this.onDragOver as EventListener);
|
||||
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter as EventListener);
|
||||
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave as EventListener);
|
||||
this.canvas.canvas.removeEventListener('drop', this.onDrop as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +242,33 @@ export class CanvasInteractions {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
|
||||
* Zwraca layer, jeśli kliknięto w ikonę grab
|
||||
*/
|
||||
getGrabIconAtPosition(worldX: number, worldY: number): Layer | null {
|
||||
// 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(): void {
|
||||
this.interaction.mode = 'none';
|
||||
this.interaction.resizeHandle = null;
|
||||
@@ -171,15 +277,38 @@ export class CanvasInteractions {
|
||||
this.interaction.canvasMoveRect = null;
|
||||
this.interaction.hasClonedInDrag = false;
|
||||
this.interaction.transformingLayer = null;
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
handleMouseDown(e: MouseEvent): void {
|
||||
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 mods = this.getModifierState(e);
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@@ -192,11 +321,11 @@ export class CanvasInteractions {
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
if (mods.shift && mods.ctrl) {
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
@@ -222,7 +351,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) { // Środkowy przycisk
|
||||
if (e.button === 1) { // Środkowy przycisk
|
||||
this.startPanning(e);
|
||||
return;
|
||||
}
|
||||
@@ -234,6 +363,15 @@ export class CanvasInteractions {
|
||||
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);
|
||||
if (clickedLayerResult) {
|
||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||
@@ -241,7 +379,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
this.startPanning(e, true); // clearSelection = true
|
||||
}
|
||||
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
@@ -264,7 +402,7 @@ export class CanvasInteractions {
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
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;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
@@ -284,8 +422,28 @@ export class CanvasInteractions {
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
case 'transformingOutputArea':
|
||||
if (this.interaction.outputAreaTransformHandle) {
|
||||
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
|
||||
} else {
|
||||
this.updateOutputAreaTransformCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
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);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.canvasRenderer.drawMaskBrushCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -300,6 +458,7 @@ export class CanvasInteractions {
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
// Render only once after drawing is complete
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -311,6 +470,11 @@ export class CanvasInteractions {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
|
||||
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
|
||||
this.finalizeOutputAreaTransform();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
@@ -397,8 +561,17 @@ export class CanvasInteractions {
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
} else {
|
||||
// Layer transformation when layers are selected
|
||||
// Check if mouse is over any selected layer
|
||||
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();
|
||||
@@ -408,14 +581,15 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
private handleLayerWheelTransformation(e: WheelEvent): void {
|
||||
const mods = this.getModifierState(e);
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
if (mods.shift) {
|
||||
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||
} else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -462,7 +636,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
||||
const gridSize = 64;
|
||||
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
let targetHeight;
|
||||
|
||||
@@ -486,10 +660,23 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
// Always track modifier keys regardless of focus
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') 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') {
|
||||
this.interaction.isAltPressed = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
@@ -505,11 +692,12 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
// 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;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'z':
|
||||
if (e.shiftKey) {
|
||||
if (mods.shift) {
|
||||
this.canvas.redo();
|
||||
} else {
|
||||
this.canvas.undo();
|
||||
@@ -523,6 +711,17 @@ export class CanvasInteractions {
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
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:
|
||||
handled = false;
|
||||
break;
|
||||
@@ -536,7 +735,7 @@ export class CanvasInteractions {
|
||||
|
||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const step = mods.shift ? 10 : 1;
|
||||
let needsRender = false;
|
||||
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
@@ -571,6 +770,7 @@ export class CanvasInteractions {
|
||||
|
||||
handleKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Meta') this.interaction.isMetaPressed = false;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
||||
@@ -590,6 +790,7 @@ export class CanvasInteractions {
|
||||
handleBlur(): void {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isMetaPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
@@ -615,6 +816,18 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
updateCursor(worldCoords: Point): void {
|
||||
// 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);
|
||||
|
||||
if (transformTarget) {
|
||||
@@ -644,7 +857,7 @@ export class CanvasInteractions {
|
||||
originalHeight: layer.originalHeight,
|
||||
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||
};
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
|
||||
if (handle === 'rot') {
|
||||
this.interaction.mode = 'rotating';
|
||||
@@ -663,14 +876,16 @@ export class CanvasInteractions {
|
||||
|
||||
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
||||
// 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);
|
||||
if (index === -1) {
|
||||
// Ctrl-clicking unselected layer: add to selection
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
} else {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
|
||||
// User can use right-click in layers panel to deselect individual layers
|
||||
} else {
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.canvasSelection.updateSelection([layer]);
|
||||
@@ -678,25 +893,24 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
}
|
||||
|
||||
startPanningOrClearSelection(e: MouseEvent): void {
|
||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
|
||||
// Unified panning method - can optionally clear selection
|
||||
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
startCanvasResize(worldCoords: Point): void {
|
||||
this.interaction.mode = 'resizingCanvas';
|
||||
const startX = snapToGrid(worldCoords.x);
|
||||
const startY = snapToGrid(worldCoords.y);
|
||||
this.interaction.canvasResizeStart = {x: startX, y: startY};
|
||||
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
|
||||
this.interaction.canvasResizeStart = { x: startX, y: startY };
|
||||
this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
@@ -743,20 +957,18 @@ export class CanvasInteractions {
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
startPanning(e: MouseEvent): void {
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
panViewport(e: MouseEvent): void {
|
||||
const dx = e.clientX - this.interaction.panStart.x;
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / 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.onViewportChange?.();
|
||||
}
|
||||
@@ -818,7 +1030,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
if (!o) return;
|
||||
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
@@ -909,7 +1121,7 @@ export class CanvasInteractions {
|
||||
|
||||
// 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;
|
||||
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
|
||||
newCropBounds.width = 1;
|
||||
}
|
||||
if (newCropBounds.height < 1) {
|
||||
@@ -974,7 +1186,7 @@ export class CanvasInteractions {
|
||||
if (!layer) return;
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
if (!o) return;
|
||||
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);
|
||||
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
||||
@@ -1167,11 +1379,14 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
||||
// 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 ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas ||
|
||||
document.activeElement === document.body;
|
||||
document.activeElement === this.canvas.canvas;
|
||||
|
||||
if (!shouldHandle) {
|
||||
log.debug("Paste event ignored - not focused on canvas");
|
||||
@@ -1221,4 +1436,189 @@ export class CanvasInteractions {
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
|
||||
// New methods for output area transformation
|
||||
public activateOutputAreaTransform(): void {
|
||||
// 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();
|
||||
}
|
||||
|
||||
private getOutputAreaHandle(worldCoords: Point): string | null {
|
||||
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;
|
||||
}
|
||||
|
||||
private startOutputAreaTransform(handle: string, worldCoords: Point): void {
|
||||
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: { [key: string]: Point } = {
|
||||
'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];
|
||||
}
|
||||
|
||||
private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
|
||||
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();
|
||||
}
|
||||
|
||||
private updateOutputAreaTransformCursor(worldCoords: Point): void {
|
||||
const handle = this.getOutputAreaHandle(worldCoords);
|
||||
|
||||
if (handle) {
|
||||
const cursorMap: { [key: string]: string } = {
|
||||
'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';
|
||||
}
|
||||
}
|
||||
|
||||
private finalizeOutputAreaTransform(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export class CanvasLayers {
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (!this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [];
|
||||
@@ -135,6 +136,142 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically adjust output area to fit selected layers
|
||||
* Calculates precise bounding box for all selected layers including rotation and crop mode support
|
||||
*/
|
||||
autoAdjustOutputToSelection(): boolean {
|
||||
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate bounding box of selected layers
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
selectedLayers.forEach((layer: Layer) => {
|
||||
// For crop mode layers, use the visible crop bounds
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
|
||||
const cropWidth = layer.cropBounds.width * layerScaleX;
|
||||
const cropHeight = layer.cropBounds.height * layerScaleY;
|
||||
|
||||
const effectiveCropX = layer.flipH
|
||||
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
|
||||
: layer.cropBounds.x;
|
||||
const effectiveCropY = layer.flipV
|
||||
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
|
||||
: layer.cropBounds.y;
|
||||
|
||||
const cropOffsetX = effectiveCropX * layerScaleX;
|
||||
const cropOffsetY = effectiveCropY * layerScaleY;
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
// Calculate corners of the crop rectangle
|
||||
const corners = [
|
||||
{ x: cropOffsetX, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
|
||||
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
|
||||
];
|
||||
|
||||
corners.forEach(p => {
|
||||
// Transform to layer space (centered)
|
||||
const localX = p.x - layer.width / 2;
|
||||
const localY = p.y - layer.height / 2;
|
||||
|
||||
// Apply rotation
|
||||
const worldX = centerX + (localX * cos - localY * sin);
|
||||
const worldY = centerY + (localX * sin + localY * cos);
|
||||
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
} else {
|
||||
// For normal layers, use the full layer bounds
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate new dimensions without padding for precise fit
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
log.error("Cannot calculate valid output area dimensions");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update output area bounds
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
// Update canvas dimensions
|
||||
this.canvas.width = newWidth;
|
||||
this.canvas.height = newHeight;
|
||||
this.canvas.maskTool.resize(newWidth, newHeight);
|
||||
this.canvas.canvas.width = newWidth;
|
||||
this.canvas.canvas.height = newHeight;
|
||||
|
||||
// Reset extensions
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.canvas.outputAreaExtensionEnabled = false;
|
||||
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
|
||||
// Update original canvas size and position
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
|
||||
|
||||
// Save state and render
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
|
||||
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pasteLayers(): void {
|
||||
if (this.internalClipboard.length === 0) return;
|
||||
this.canvas.saveState();
|
||||
@@ -266,6 +403,7 @@ export class CanvasLayers {
|
||||
tempCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
const newImage = new Image();
|
||||
newImage.crossOrigin = 'anonymous';
|
||||
newImage.src = tempCanvas.toDataURL();
|
||||
layer.image = newImage;
|
||||
}
|
||||
@@ -864,6 +1002,7 @@ export class CanvasLayers {
|
||||
|
||||
// Convert canvas to image
|
||||
const processedImage = new Image();
|
||||
processedImage.crossOrigin = 'anonymous';
|
||||
processedImage.src = processedCanvas.toDataURL();
|
||||
return processedImage;
|
||||
}
|
||||
@@ -1124,8 +1263,8 @@ export class CanvasLayers {
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
// Don't set canvas.width/height - the render loop will handle display size
|
||||
// this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
@@ -1884,6 +2023,7 @@ export class CanvasLayers {
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
|
||||
const fusedImage = new Image();
|
||||
fusedImage.crossOrigin = 'anonymous';
|
||||
fusedImage.src = tempCanvas.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
fusedImage.onload = resolve;
|
||||
|
||||
@@ -121,6 +121,7 @@ export class CanvasLayersPanel {
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
@@ -135,6 +136,7 @@ export class CanvasLayersPanel {
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
this.setupMasterVisibilityToggle();
|
||||
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
@@ -142,6 +144,26 @@ export class CanvasLayersPanel {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,6 +191,74 @@ export class CanvasLayersPanel {
|
||||
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 {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
@@ -186,10 +276,11 @@ export class CanvasLayersPanel {
|
||||
|
||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if(this.layersContainer)
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
|
||||
if (this._updateMasterVisibilityToggle) this._updateMasterVisibilityToggle();
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
|
||||
@@ -317,6 +408,9 @@ export class CanvasLayersPanel {
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,19 @@ export class CanvasRenderer {
|
||||
lastRenderTime: any;
|
||||
renderAnimationFrame: any;
|
||||
renderInterval: any;
|
||||
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.renderAnimationFrame = null;
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
|
||||
// Initialize overlay canvases
|
||||
this.initOverlay();
|
||||
this.initStrokeOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,9 +148,11 @@ export class CanvasRenderer {
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
// In draw mask mode, use the previewOpacity value from the slider
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||
} else {
|
||||
// When not in draw mask mode, show mask at full opacity
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
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.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
@@ -186,6 +200,7 @@ export class CanvasRenderer {
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
// Update custom shape menu position and visibility
|
||||
@@ -205,6 +220,12 @@ export class CanvasRenderer {
|
||||
}
|
||||
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
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||
@@ -710,4 +731,392 @@ export class CanvasRenderer {
|
||||
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.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
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) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||
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.`);
|
||||
// Don't return false - allow empty canvas to be valid
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
@@ -235,6 +235,7 @@ export class CanvasState {
|
||||
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
@@ -250,6 +251,7 @@ export class CanvasState {
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
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) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
|
||||
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||
this.canvas.maskTool.restoreMaskFromSavedState(prevState);
|
||||
|
||||
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||
|
||||
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();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
|
||||
// Use the new restoreMaskFromSavedState method that properly clears chunks first
|
||||
this.canvas.maskTool.restoreMaskFromSavedState(nextState);
|
||||
|
||||
// Clear stroke overlay to prevent old drawing previews from persisting
|
||||
this.canvas.canvasRenderer.clearMaskStrokeOverlay();
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
|
||||
@@ -268,90 +268,32 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
$el("div.painter-separator"),
|
||||
$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", {
|
||||
textContent: "Output Area Size",
|
||||
title: "Set the size of the output area",
|
||||
title: "Transform output area - drag handles to resize",
|
||||
onclick: () => {
|
||||
const dialog = $el("div.painter-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
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);
|
||||
};
|
||||
// Activate output area transform mode
|
||||
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
@@ -476,13 +418,46 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement;
|
||||
if (button.classList.contains('loading')) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||
throw new Error("Please select exactly one image layer for matting.");
|
||||
}
|
||||
@@ -501,7 +476,18 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||
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);
|
||||
}
|
||||
@@ -526,10 +512,15 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
} catch (error: any) {
|
||||
log.error("Matting error:", error);
|
||||
const errorMessage = error.message || "An unknown error occurred.";
|
||||
if (!errorMessage.includes("Network Connection Error") &&
|
||||
!errorMessage.includes("Matting Model Error") &&
|
||||
!errorMessage.includes("Dependency Not Found")) {
|
||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||
}
|
||||
} finally {
|
||||
button.classList.remove('loading');
|
||||
if (button.contains(spinner)) {
|
||||
const spinner = button.querySelector('.matting-spinner');
|
||||
if (spinner && button.contains(spinner)) {
|
||||
button.removeChild(spinner);
|
||||
}
|
||||
}
|
||||
@@ -640,6 +631,24 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
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("label", {for: "brush-size-slider", textContent: "Size:"}),
|
||||
$el("input", {
|
||||
@@ -991,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
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', () => {
|
||||
canvasContainer.classList.add('has-focus');
|
||||
});
|
||||
@@ -1011,7 +1027,9 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
}
|
||||
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
||||
|
||||
if (node.addDOMWidget) {
|
||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||
}
|
||||
|
||||
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
|
||||
let backdrop: HTMLDivElement | null = null;
|
||||
@@ -1123,7 +1141,12 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
if (!(window as any).canvasExecutionStates) {
|
||||
(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(() => {
|
||||
canvas.loadInitialState();
|
||||
@@ -1145,7 +1168,7 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1179,12 +1202,23 @@ app.registerExtension({
|
||||
|
||||
const sendPromises: Promise<any>[] = [];
|
||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
} else {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
|
||||
if (!node) {
|
||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,6 +1239,9 @@ app.registerExtension({
|
||||
|
||||
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
||||
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;
|
||||
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
@@ -1238,9 +1275,198 @@ app.registerExtension({
|
||||
canvasNodeInstances.set(this.id, canvasWidget);
|
||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.setDirtyCanvas(true, true);
|
||||
// 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(() => {
|
||||
if (this.inputs && this.inputs.length > 0) {
|
||||
// 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;
|
||||
@@ -1278,6 +1504,52 @@ app.registerExtension({
|
||||
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;
|
||||
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
@@ -1377,8 +1649,8 @@ app.registerExtension({
|
||||
callback: async () => {
|
||||
try {
|
||||
log.info("Opening LayerForge canvas in MaskEditor");
|
||||
if ((self as any).canvasWidget && (self as any).canvasWidget.startMaskEditor) {
|
||||
await (self as any).canvasWidget.startMaskEditor(null, true);
|
||||
if ((self as any).canvasWidget && (self as any).canvasWidget.canvas) {
|
||||
await (self as any).canvasWidget.canvas.startMaskEditor(null, true);
|
||||
} else {
|
||||
log.error("Canvas widget not available");
|
||||
showErrorNotification("Canvas not ready. Please try again.");
|
||||
@@ -1393,8 +1665,8 @@ app.registerExtension({
|
||||
content: "Open Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
@@ -1408,8 +1680,8 @@ app.registerExtension({
|
||||
content: "Open Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
@@ -1423,8 +1695,8 @@ app.registerExtension({
|
||||
content: "Copy Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
@@ -1439,8 +1711,8 @@ app.registerExtension({
|
||||
content: "Copy Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
@@ -1455,8 +1727,8 @@ app.registerExtension({
|
||||
content: "Save Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -1475,8 +1747,8 @@ app.registerExtension({
|
||||
content: "Save Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -507,7 +507,6 @@ export class MaskEditorIntegration {
|
||||
maskSize: {width: bounds.width, height: bounds.height}
|
||||
});
|
||||
|
||||
// Use the chunk system instead of direct canvas manipulation
|
||||
this.maskTool.setMask(maskAsImage);
|
||||
|
||||
// Update node preview using PreviewUtils
|
||||
|
||||
285
src/MaskTool.ts
285
src/MaskTool.ts
@@ -21,9 +21,10 @@ interface MaskChunk {
|
||||
}
|
||||
|
||||
export class MaskTool {
|
||||
private brushHardness: number;
|
||||
private brushSize: number;
|
||||
private brushStrength: number;
|
||||
private _brushHardness: number;
|
||||
public brushSize: number;
|
||||
private _brushStrength: number;
|
||||
private _previewOpacity: number;
|
||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||
public isActive: boolean;
|
||||
public isDrawing: boolean;
|
||||
@@ -31,6 +32,9 @@ export class MaskTool {
|
||||
private lastPosition: Point | null;
|
||||
private mainCanvas: HTMLCanvasElement;
|
||||
|
||||
// Track strokes during drawing for efficient overlay updates
|
||||
private currentStrokePoints: Point[] = [];
|
||||
|
||||
// Chunked mask system
|
||||
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
||||
private chunkSize: number;
|
||||
@@ -72,6 +76,9 @@ export class MaskTool {
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
|
||||
// Initialize stroke tracking for overlay drawing
|
||||
this.currentStrokePoints = [];
|
||||
|
||||
// Initialize chunked mask system
|
||||
this.maskChunks = new Map();
|
||||
this.chunkSize = 512;
|
||||
@@ -96,8 +103,9 @@ export class MaskTool {
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this._brushStrength = 0.5;
|
||||
this._brushHardness = 0.5;
|
||||
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||
this.isDrawing = false;
|
||||
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 {
|
||||
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 {
|
||||
@@ -867,7 +898,7 @@ export class MaskTool {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -875,10 +906,12 @@ export class MaskTool {
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
|
||||
// Activate chunks around the drawing position for performance
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
// Initialize stroke tracking for live preview
|
||||
this.currentStrokePoints = [worldCoords];
|
||||
|
||||
// Clear any previous stroke overlay
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
|
||||
this.draw(worldCoords);
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
@@ -888,16 +921,83 @@ export class MaskTool {
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing) return;
|
||||
|
||||
// Dynamically update active chunks as user moves while drawing
|
||||
this.updateActiveChunksForDrawing(worldCoords);
|
||||
// Add point to stroke tracking
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
// Clear overlay canvases when mouse leaves
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||
}
|
||||
|
||||
handleMouseEnter(): void {
|
||||
@@ -908,11 +1008,18 @@ export class MaskTool {
|
||||
if (!this.isActive) return;
|
||||
if (this.isDrawing) {
|
||||
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.currentDrawingChunk = null;
|
||||
|
||||
// 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.drawBrushPreview(viewCoords);
|
||||
@@ -932,6 +1039,44 @@ export class MaskTool {
|
||||
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
|
||||
*/
|
||||
@@ -982,15 +1127,15 @@ export class MaskTool {
|
||||
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
|
||||
if (this.brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
if (this._brushHardness === 1) {
|
||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||
} 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
|
||||
);
|
||||
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)`);
|
||||
chunk.ctx.strokeStyle = gradient;
|
||||
}
|
||||
@@ -1029,29 +1174,17 @@ export class MaskTool {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
||||
* During drawing, only updates the affected active chunks for performance
|
||||
* Updates active canvas when drawing affects chunks
|
||||
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||
*/
|
||||
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
||||
// Calculate which chunks were affected by this drawing operation
|
||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
||||
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 {
|
||||
// This method is now simplified - we only update after drawing is complete
|
||||
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||
if (!this.isDrawing) {
|
||||
// Not drawing - do full update to show all chunks
|
||||
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 {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
|
||||
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();
|
||||
// Use overlay canvas instead of preview canvas for brush cursor
|
||||
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||
}
|
||||
|
||||
clearPreview(): void {
|
||||
@@ -1548,6 +1674,27 @@ export class MaskTool {
|
||||
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 {
|
||||
// Return the current active mask canvas which shows all chunks
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
setMask(image: HTMLImageElement): void {
|
||||
// Clear existing mask chunks in the output area first
|
||||
setMask(image: HTMLImageElement, isFromInputMask: boolean = false): void {
|
||||
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) {
|
||||
// For INPUT MASK - process black background to transparent using luminance
|
||||
// Center like input images
|
||||
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 new mask using chunk system at bounds (${bounds.x}, ${bounds.y})`);
|
||||
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";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
@@ -282,7 +283,31 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
||||
log.debug("Attempting to reload SAM result image");
|
||||
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
|
||||
if (originalSrc.startsWith('data:')) {
|
||||
log.debug("Image is a data URL, skipping reload with parameters");
|
||||
// For data URLs, just ensure the image is loaded
|
||||
if (!resultImage.complete || resultImage.naturalWidth === 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resultImage.width = img.width;
|
||||
resultImage.height = img.height;
|
||||
log.debug("Data URL image loaded successfully", {
|
||||
width: img.width,
|
||||
height: img.height
|
||||
});
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
log.error("Failed to load data URL image", error);
|
||||
reject(error);
|
||||
};
|
||||
img.src = originalSrc; // Use original src without modifications
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For regular URLs, add cache-busting parameter
|
||||
const url = new URL(originalSrc);
|
||||
url.searchParams.set('_t', Date.now().toString());
|
||||
|
||||
@@ -313,6 +338,7 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
||||
img.src = url.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load image from SAM Detector.", error);
|
||||
showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
|
||||
@@ -333,32 +359,43 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
|
||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||
log.debug("Checking canvas and maskTool availability", {
|
||||
hasCanvas: !!canvas,
|
||||
hasCanvasProperty: !!canvas.canvas,
|
||||
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
|
||||
hasMaskTool: !!canvas.maskTool,
|
||||
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
|
||||
maskToolType: typeof canvas.maskTool,
|
||||
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
|
||||
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:", {
|
||||
hasCanvas: !!canvas,
|
||||
hasActualCanvas: !!actualCanvas,
|
||||
canvasConstructor: canvas.constructor.name,
|
||||
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
|
||||
canvasKeys: Object.keys(canvas),
|
||||
maskToolValue: canvas.maskTool
|
||||
actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
|
||||
maskToolValue: maskTool
|
||||
});
|
||||
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
|
||||
canvas.maskTool.addMask(maskAsImage);
|
||||
// Use the setMask method which clears existing mask and sets new one
|
||||
maskTool.setMask(maskAsImage);
|
||||
|
||||
// Update canvas and save state (same as MaskEditorIntegration)
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
actualCanvas.render();
|
||||
actualCanvas.saveState();
|
||||
|
||||
// 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");
|
||||
|
||||
@@ -399,15 +436,23 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||
|
||||
// Automatically send canvas to clipspace and start monitoring
|
||||
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
||||
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
||||
if ((node as any).canvasWidget) {
|
||||
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, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
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
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
(node as any).clipspaceImg = uploadResult.imageElement;
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -23,6 +23,85 @@
|
||||
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;
|
||||
|
||||
29
src/types.ts
29
src/types.ts
@@ -1,6 +1,14 @@
|
||||
import type { Canvas as CanvasClass } from './Canvas';
|
||||
import type { CanvasLayers } from './CanvasLayers';
|
||||
|
||||
export interface ComfyWidget {
|
||||
name: string;
|
||||
type: string;
|
||||
value: any;
|
||||
callback?: (value: any) => void;
|
||||
options?: any;
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
image: HTMLImageElement;
|
||||
@@ -32,15 +40,16 @@ export interface Layer {
|
||||
|
||||
export interface ComfyNode {
|
||||
id: number;
|
||||
type: string;
|
||||
widgets: ComfyWidget[];
|
||||
imgs?: HTMLImageElement[];
|
||||
widgets: any[];
|
||||
size: [number, number];
|
||||
graph: any;
|
||||
canvasWidget?: any;
|
||||
size?: [number, number];
|
||||
onResize?: () => void;
|
||||
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
|
||||
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
|
||||
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
|
||||
setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
|
||||
graph?: any;
|
||||
onRemoved?: () => void;
|
||||
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
|
||||
inputs?: Array<{ link: any }>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -79,8 +88,14 @@ export interface Canvas {
|
||||
imageCache: any;
|
||||
dataInitialized: boolean;
|
||||
pendingDataCheck: number | null;
|
||||
pendingInputDataCheck: number | null;
|
||||
pendingBatchContext: any;
|
||||
canvasLayers: any;
|
||||
inputDataLoaded: boolean;
|
||||
lastLoadedLinkId: any;
|
||||
lastLoadedMaskLinkId: any;
|
||||
lastLoadedImageSrc?: string;
|
||||
outputAreaBounds: OutputAreaBounds;
|
||||
saveState: () => void;
|
||||
render: () => void;
|
||||
updateSelection: (layers: Layer[]) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
|
||||
@@ -34,6 +34,7 @@ export class ClipboardManager {
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
showInfoNotification("Layers pasted from internal clipboard");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -44,10 +45,22 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
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");
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -72,6 +85,7 @@ export class ClipboardManager {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from Clipspace");
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
@@ -105,6 +119,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from system clipboard");
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result as string;
|
||||
@@ -148,13 +163,24 @@ export class ClipboardManager {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
if (text) {
|
||||
// Check if it's a data URI (base64 encoded image)
|
||||
if (this.isDataURI(text)) {
|
||||
log.info("Found data URI in clipboard");
|
||||
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) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
@@ -165,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
|
||||
* @param {string} text - The text to validate
|
||||
@@ -243,10 +313,12 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from URL");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
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;
|
||||
@@ -326,6 +398,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from file path");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -366,6 +439,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from selected file");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
|
||||
@@ -386,3 +386,111 @@ export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageE
|
||||
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');
|
||||
|
||||
// Store active notifications for deduplication
|
||||
const activeNotifications = new Map<string, { element: HTMLDivElement, timeout: number | null }>();
|
||||
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
@@ -11,16 +14,62 @@ const log = createModuleLogger('NotificationUtils');
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @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: string,
|
||||
backgroundColor: string = "#4a6cd4",
|
||||
duration: number = 3000,
|
||||
type: "success" | "error" | "info" | "warning" | "alert" = "info"
|
||||
type: "success" | "error" | "info" | "warning" | "alert" = "info",
|
||||
deduplicate: boolean = false
|
||||
): void {
|
||||
// Remove any existing prefix to avoid double prefixing
|
||||
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
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
@@ -172,6 +221,11 @@ export function showNotification(
|
||||
|
||||
let dismissTimeout: number | null = null;
|
||||
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.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
@@ -198,46 +252,86 @@ export function showNotification(
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
notification.addEventListener('mouseenter', () => {
|
||||
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();
|
||||
|
||||
// Store notification if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||
}
|
||||
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "success", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "error", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "info", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,7 +342,7 @@ export function showAllNotificationTypes(message?: string): void {
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user