49 Commits

Author SHA1 Message Date
Dariusz L
aca1f4e422 Update pyproject.toml 2025-07-02 10:37:39 +02:00
Dariusz L
195e25437a Improve matting error handling and user feedback
Adds checks for missing 'transformers' dependency and network errors in the matting endpoint, returning clear error messages for common failure cases. Updates the frontend to display more informative alerts to users when matting fails, including details from server responses.
2025-07-02 10:36:22 +02:00
Dariusz L
d1004d5864 Notify layers panel on layer changes
Added calls to canvasLayersPanel.onLayersChanged() after pasting, adding, and fusing layers to ensure the layers panel updates its view in response to these actions.
2025-07-02 10:16:59 +02:00
Dariusz L
d2ccfc4e20 Update undo/redo button handlers in CanvasView
Changed the undo and redo button onclick handlers to call canvas.undo() and canvas.redo() instead of canvas.canvasState.undo() and canvas.canvasState.redo(). This aligns the button actions with the updated canvas API.
2025-07-02 10:08:32 +02:00
Dariusz L
2c313f43e8 Enable keyboard delete in layers panel
Added keyboard event listener to allow deleting selected layers using the Delete or Backspace keys when the layers panel is focused. This improves accessibility and user experience.
2025-07-02 10:05:54 +02:00
Dariusz L
2636521026 Remove keyboard event handling from layers panel
Eliminated tabindex and keydown event listener from the CanvasLayersPanel container. Keyboard interactions are no longer handled directly in this panel.
2025-07-02 10:00:30 +02:00
Dariusz L
e0a4549321 Refactor tooltip positioning logic in CanvasView
Extracted tooltip positioning code into reusable showTooltip and hideTooltip helper functions. Updated event handlers to use these helpers, reducing code duplication and improving maintainability.
2025-07-02 09:53:35 +02:00
Dariusz L
29ab916759 Refactor layers panel UI and improve resize handling
Changed UI text in CanvasLayersPanel from Polish to English and removed the add layer button and its logic. Moved and improved the ResizeObserver logic in CanvasView.js to update both the canvas and layers panel positions dynamically based on the controls' height.
2025-07-02 09:40:21 +02:00
Dariusz L
ac21aa9579 Enable keyboard interaction in layers panel
Added tabIndex to the panel container to allow keyboard focus and attached a keydown event listener to forward keyboard events to the main interaction handler. This improves accessibility and enables keyboard-based layer deletion from the panel.
2025-07-02 09:33:13 +02:00
Dariusz L
cae24310db Adjust canvas and panel layout widths
Reduced the right margin of the canvas from 320px to 270px and the panel width from 300px to 250px to optimize space allocation in the UI.
2025-07-02 09:25:33 +02:00
Dariusz L
7d8fd30bbf Add snap-to-5° rotation with Shift+Ctrl+Mouse Wheel
Introduces a new shortcut (Shift + Ctrl + Mouse Wheel) to snap layer rotation to the nearest 5° increment. Updates the CanvasView help table to document this new functionality and clarifies the description for existing rotation shortcuts.
2025-07-02 09:21:47 +02:00
Dariusz L
244d48728c Refactor canvas interactions and update keyboard shortcuts
Reorganizes mouse and keyboard event handling in CanvasInteractions for clearer priority and improved usability. Adds global keyboard shortcuts for undo, redo, copy, and paste. Updates context-sensitive shortcuts to support both Delete and Backspace for layer removal. Refactors CanvasLayers to use delayed state saving via requestSaveState. Updates CanvasView shortcut documentation to reflect these changes and clarify mouse/keyboard actions.
2025-07-02 09:16:16 +02:00
Dariusz L
ef01be3323 Add canvas move and resize interactions
Implemented new interaction modes for moving and resizing the canvas using shift and alt modifiers. Added corresponding handlers for mouse events to support these actions.
2025-07-02 08:48:41 +02:00
Dariusz L
b3d1206f3f Refactor layer selection and movement logic
Centralizes layer movement logic in CanvasLayers with a new moveLayers function, supporting both up/down and drag-and-drop reordering. Updates selection logic in Canvas to only trigger updates when selection changes, and improves event handling in CanvasLayersPanel for more responsive selection and drag operations. Removes redundant moveLayersToPosition method in favor of the new unified approach.
2025-07-02 08:41:18 +02:00
Dariusz L
a73a3dcf96 Add layers panel UI and improve layer management
Introduces a new CanvasLayersPanel component for managing layers visually, including selection, renaming, reordering via drag-and-drop, and deletion. Integrates the panel into the main Canvas and CanvasView, synchronizes selection and state changes, and adds logic for duplicating layers and debounced state saving. Moves IndexedDB state saving to a Web Worker for better performance. Also sets default logger level to DEBUG for improved diagnostics.
2025-07-02 08:09:49 +02:00
Dariusz L
53aa35491e Add layer fusion (flatten/merge) feature
Introduces a new 'Fuse' button to the canvas UI, allowing users to flatten and merge multiple selected layers into a single layer. The implementation handles bounding box calculation, z-index ordering, and updates the canvas state and selection accordingly. The fuse button is enabled only when at least two layers are selected.
2025-07-02 00:42:38 +02:00
Dariusz L
b3b901a8d6 Update pyproject.toml 2025-07-02 00:22:22 +02:00
Dariusz L
826f448af9 Add documentation for core modules and update Canvas.js
Added documentation files for ComfyApi, ComfyApp, LitegraphService, and MaskEditor, summarizing their main functions and usage. Refactored js/Canvas.js to improve mask processing logic, using viewport pan for cropping and applying mask color only to non-transparent pixels. Also made minor formatting and logging consistency improvements throughout Canvas.js.
2025-07-02 00:21:53 +02:00
Dariusz L
42e13f1551 Update pyproject.toml 2025-07-01 17:03:53 +02:00
Dariusz L
562b0db042 Initial commit
Project scaffolding and initial file setup.
2025-07-01 17:02:15 +02:00
Dariusz L
038dad759a Update bug_report.yml 2025-07-01 12:07:57 +02:00
Dariusz L
6f4602eb31 Update README.md 2025-07-01 12:03:13 +02:00
Dariusz L
cac7652b7d Create docs_request.yml 2025-06-30 14:30:11 +02:00
Dariusz L
d5573f426c Create bug_report.yml 2025-06-30 14:29:01 +02:00
Dariusz L
979fcd59bc Update ComfyUIdownloads.yml 2025-06-29 16:18:10 +02:00
Dariusz L
4ec470a3ed Add LayerForge test workflow example
Added a new example workflow for LayerForge, including a JSON workflow file and a corresponding PNG diagram. This provides a reference for users to understand and test LayerForge integration and node connections.
2025-06-29 16:03:10 +02:00
Dariusz L
8a456db6a0 Update README.md 2025-06-29 15:56:17 +02:00
Dariusz L
55a60d710c Update ComfyUIdownloads.yml 2025-06-29 15:51:42 +02:00
Dariusz L
e40c85b0ee Update ComfyUIdownloads.yml 2025-06-29 15:46:30 +02:00
Dariusz L
145d64ea39 Update ComfyUIdownloads.yml 2025-06-29 15:36:16 +02:00
Dariusz L
281350f75a Update ComfyUIdownloads.yml 2025-06-29 15:30:31 +02:00
Dariusz L
dc3197e914 Update ComfyUIdownloads.yml 2025-06-29 15:28:12 +02:00
Dariusz L
5a71eb46db Update release.yml 2025-06-29 15:20:15 +02:00
Dariusz L
35d3c77ba8 Update ComfyUIdownloads.yml 2025-06-29 15:15:11 +02:00
GitHub Action
813df556fb Create LayerForge badge 2025-06-29 12:41:37 +00:00
Dariusz L
6372aea90c Create ComfyUIdownloads.yml 2025-06-29 14:40:41 +02:00
Dariusz L
9ab8680a85 Update README.md 2025-06-29 04:57:31 +02:00
Dariusz L
90a0c6476f Update README.md 2025-06-29 04:51:48 +02:00
Dariusz L
3544576605 Revert "Add mask editor integration to canvas workflow"
This reverts commit 7a7c8f2295.
2025-06-29 04:49:56 +02:00
Dariusz L
3b16c00b66 Revert "Refactor Canvas class as facade and clean up CanvasLayers"
This reverts commit 9dcf38b36d.
2025-06-29 04:49:49 +02:00
Dariusz L
d0ade5ebc7 Revert "Update README.md"
This reverts commit fc8ebedb1e.
2025-06-29 04:45:45 +02:00
Dariusz L
9dcf38b36d Refactor Canvas class as facade and clean up CanvasLayers
Refactored the Canvas class to act as a facade, providing a simplified high-level interface and delegating detailed operations to internal modules. Added Polish documentation, grouped and clarified main operations, and moved legacy/delegation methods to the end for backward compatibility. Removed unused or redundant methods from CanvasLayers.js, such as removeLayer, moveLayer, addMattedLayer, isRotationHandle, getResizeHandle, handleBlendModeSelection, and getFlattenedCanvasAsDataURL, to streamline the codebase.
2025-06-29 04:41:48 +02:00
Dariusz L
7a7c8f2295 Add mask editor integration to canvas workflow
Introduces the ability to open the current canvas in a mask editor, upload and retrieve mask edits, and apply them to the mask layer. Adds utility functions for mask editor state detection and control, a new 'Edit Mask' button in the UI, and methods for handling mask updates and preview refresh. Also adds a setMask method to MaskTool for precise mask placement.
2025-06-29 04:15:45 +02:00
Dariusz L
fc8ebedb1e Update README.md 2025-06-29 00:47:53 +02:00
GitHub Action
98037324cd create clone count badge 2025-06-28 22:35:15 +00:00
Dariusz L
372a7a4718 Update clone.yml 2025-06-29 00:28:22 +02:00
Dariusz L
8a18e4ec30 Update clone.yml 2025-06-29 00:24:49 +02:00
Dariusz L
ade3cd7818 Add GitHub clone count badge workflow
Introduces a GitHub Actions workflow to update and display the repository's clone count using a dynamic badge. Updates README.md to include the clone count badge.
2025-06-29 00:22:15 +02:00
Dariusz L
4a9dc3219b Update release.yml 2025-06-29 00:06:47 +02:00
32 changed files with 5044 additions and 1036 deletions

98
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: 🐞 Bug Report
description: Report an error or unexpected behavior
title: "[BUG] "
labels: [bug]
body:
- type: markdown
attributes:
value: |
**Thank you for reporting a bug!**
Please follow these steps to capture useful info:
### How to gather the necessary information:
🌐 **Browser & Version:**
- Chrome: Click the three dots → Help → About Google Chrome
- Firefox: Click the three bars → Help → About Firefox
- Edge: Click the three dots → Help and feedback → About Microsoft Edge
🔗 **Where to find the latest versions of ComfyUI and LayerForge:**
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases/tag/v1.2.4) or [LayerForge from manager Comfyui](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
Make sure you have the latest versions before reporting an issue.
- 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
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: console_logs
attributes:
label: Browser Console Logs
description: |
**How to capture logs:**
- **Open console:**
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
Mac: `Cmd+Option+J`
- Firefox (Win/Linux): `Ctrl+Shift+K`
Mac: `Cmd+Option+K`
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
- **Clear console** before reproducing:
- Chrome/Edge: click “🚫 Clear console” or press `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac)
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux), Mac: `Cmd+K`
- Safari: click 🗑 icon or press `Cmd+K` / `Ctrl+L`
- Reproduce the issue and paste new logs here.
validations:
required: true
- type: markdown
attributes:
value: |
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
To add media, simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.

24
.github/ISSUE_TEMPLATE/docs_request.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: 📝 Documentation Request
description: Suggest improvements or additions to documentation
title: "[Docs] "
labels: [documentation]
body:
- type: input
id: doc_area
attributes:
label: Area of documentation
placeholder: e.g. Getting started, Node API, Deployment guide
validations:
required: true
- type: textarea
id: current_issue
attributes:
label: What's wrong or missing?
placeholder: Describe the gap or confusing part
validations:
required: true
- type: textarea
id: suggested_content
attributes:
label: How should it be improved?
placeholder: Provide concrete suggestions or examples

143
.github/workflows/ComfyUIdownloads.yml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: LayerForge Top Downloads Badge
on:
schedule:
- cron: "0 0,8,16 * * *"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: gh login
run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token
- name: Query LayerForge API 20 times and find top download
run: |
max_downloads=0
top_node_json="{}"
for i in {1..20}; do
echo "Pobieranie danych z próby $i..."
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json
if [ ! -s tmp_$i.json ] || ! jq empty tmp_$i.json 2>/dev/null; then
echo "Błąd: Nieprawidłowy JSON dla próby $i"
continue
fi
if jq -e 'type == "array"' tmp_$i.json >/dev/null; then
# Przeszukanie wszystkich węzłów w tablicy
node_count=$(jq 'length' tmp_$i.json)
echo "Znaleziono $node_count węzłów w próbie $i"
for j in $(seq 0 $((node_count - 1))); do
downloads=$(jq -r ".[$j].downloads // 0" tmp_$i.json)
name=$(jq -r ".[$j].name // \"\"" tmp_$i.json)
if [ "$downloads" -gt "$max_downloads" ]; then
max_downloads=$downloads
top_node_json=$(jq ".[$j]" tmp_$i.json)
echo "Nowe maksimum znalezione: $downloads (węzeł: $name)"
fi
done
else
downloads=$(jq -r '.downloads // 0' tmp_$i.json)
name=$(jq -r '.name // ""' tmp_$i.json)
if [ "$downloads" -gt "$max_downloads" ]; then
max_downloads=$downloads
top_node_json=$(cat tmp_$i.json)
echo "Nowe maksimum znalezione: $downloads (węzeł: $name)"
fi
fi
rm -f tmp_$i.json
done
if [ "$max_downloads" -gt 0 ]; then
echo "$top_node_json" > top_layerforge.json
echo "Najwyższa liczba pobrań: $max_downloads"
echo "Szczegóły węzła:"
jq . top_layerforge.json
else
echo "Błąd: Nie znaleziono żadnych prawidłowych danych"
# Utworzenie domyślnego JSON-a
echo '{"name": "No data", "downloads": 0}' > top_layerforge.json
fi
- name: create or update gist with top download
id: set_id
run: |
if gh secret list | grep -q "LAYERFORGE_GIST_ID"
then
echo "GIST_ID found"
echo "GIST=${{ secrets.LAYERFORGE_GIST_ID }}" >> $GITHUB_OUTPUT
# Sprawdzenie czy gist istnieje
if gh gist view ${{ secrets.LAYERFORGE_GIST_ID }} &>/dev/null; then
echo "Gist istnieje, będzie zaktualizowany"
else
echo "Gist nie istnieje, tworzenie nowego"
gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}')
echo $gist_id | gh secret set LAYERFORGE_GIST_ID
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
fi
else
echo "Tworzenie nowego gist"
gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}')
echo $gist_id | gh secret set LAYERFORGE_GIST_ID
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
fi
- name: create badge if needed
run: |
COUNT=$(jq '.downloads' top_layerforge.json)
NAME=$(jq -r '.name' top_layerforge.json)
if [ ! -f LAYERFORGE.md ]; then
shields="https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url="
url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/top_layerforge.json"
repo="https://comfy.org"
echo ''> LAYERFORGE.md
echo '
**Markdown**
```markdown' >> LAYERFORGE.md
echo "[![Top LayerForge Node]($shields$url)]($repo)" >> LAYERFORGE.md
echo '
```
**HTML**
```html' >> LAYERFORGE.md
echo "<a href='$repo'><img alt='Top LayerForge Node' src='$shields$url'></a>" >> LAYERFORGE.md
echo '```' >> LAYERFORGE.md
git add LAYERFORGE.md
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git commit -m "Create LayerForge badge"
fi
- name: Update Gist
run: |
# Upewnienie się, że JSON jest poprawny
if jq empty top_layerforge.json 2>/dev/null; then
content=$(jq -c . top_layerforge.json)
echo "{\"description\": \"Top LayerForge Node\", \"files\": {\"top_layerforge.json\": {\"content\": $(jq -Rs . <<< "$content")}}}" > patch.json
curl -s -X PATCH \
--user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
-H "Content-Type: application/json" \
-d @patch.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }}
else
echo "Błąd: Nieprawidłowy JSON w top_layerforge.json"
exit 1
fi
- name: Push
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

90
.github/workflows/clone.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: GitHub Clone Count Update Everyday
on:
schedule:
- cron: "0 */24 * * *"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: gh login
run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token
- name: parse latest clone count
run: |
curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/traffic/clones \
> clone.json
- name: create gist and download previous count
id: set_id
run: |
if gh secret list | grep -q "GIST_ID"
then
echo "GIST_ID found"
echo "GIST=${{ secrets.GIST_ID }}" >> $GITHUB_OUTPUT
curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.GIST_ID }}/raw/clone.json > clone_before.json
if cat clone_before.json | grep '404: Not Found'; then
echo "GIST_ID not valid anymore. Creating another gist..."
gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
echo $gist_id | gh secret set GIST_ID
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
cp clone.json clone_before.json
git rm --ignore-unmatch CLONE.md
fi
else
echo "GIST_ID not found. Creating a gist..."
gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
echo $gist_id | gh secret set GIST_ID
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
cp clone.json clone_before.json
fi
- name: update clone.json
run: |
curl https://raw.githubusercontent.com/MShawon/github-clone-count-badge/master/main.py > main.py
python3 main.py
- name: Update gist with latest count
run: |
content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "clone.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g')
echo '{"description": "${{ github.repository }} clone statistics", "files": {"clone.json": {"content": "'"$content"'"}}}' > post_clone.json
curl -s -X PATCH \
--user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
-H "Content-Type: application/json" \
-d @post_clone.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1
if [ ! -f CLONE.md ]; then
shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url="
url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/clone.json"
repo="https://github.com/MShawon/github-clone-count-badge"
echo ''> CLONE.md
echo '
**Markdown**
```markdown' >> CLONE.md
echo "[![GitHub Clones]($shields$url&logo=github)]($repo)" >> CLONE.md
echo '
```
**HTML**
```html' >> CLONE.md
echo "<a href='$repo'><img alt='GitHub Clones' src='$shields$url&logo=github'></a>" >> CLONE.md
echo '```' >> CLONE.md
git add CLONE.md
git config --global user.name "GitHub Action"
git config --global user.email "action@github.com"
git commit -m "create clone count badge"
fi
- name: Push
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,4 +1,4 @@
name: Auto Release with Version Patch name: Auto Release with Version Check
on: on:
push: push:
@@ -19,22 +19,26 @@ jobs:
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
echo "base_version=$base" >> $GITHUB_OUTPUT echo "base_version=$base" >> $GITHUB_OUTPUT
- name: Determine unique version tag - name: Check if tag for this version already exists
run: |
TAG="v${{ steps.version.outputs.base_version }}"
git fetch --tags
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists. Skipping release."
exit 0
fi
- name: Set version tag
id: unique_tag id: unique_tag
run: | run: |
BASE="v${{ steps.version.outputs.base_version }}" echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
TAG=$BASE
COUNT=0
# Fetch remote tags - name: Get latest commit message
git fetch --tags id: last_commit
run: |
while git rev-parse "$TAG" >/dev/null 2>&1; do msg=$(git log -1 --pretty=%B)
COUNT=$((COUNT + 1)) msg=${msg//$'\n'/\\n}
TAG="$BASE.$COUNT" echo "commit_msg=$msg" >> $GITHUB_OUTPUT
done
echo "final_tag=$TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
@@ -43,6 +47,10 @@ jobs:
name: Release ${{ steps.unique_tag.outputs.final_tag }} name: Release ${{ steps.unique_tag.outputs.final_tag }}
body: | body: |
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}` 📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
🔁 Auto-postfix to avoid duplicate tag: `${{ steps.unique_tag.outputs.final_tag }}`
📝 Last commit message:
```
${{ steps.last_commit.outputs.commit_msg }}
```
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

13
CLONE.md Normal file
View File

@@ -0,0 +1,13 @@
**Markdown**
```markdown
[![GitHub Clones](https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github)](https://github.com/MShawon/github-clone-count-badge)
```
**HTML**
```html
<a href='https://github.com/MShawon/github-clone-count-badge'><img alt='GitHub Clones' src='https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github'></a>
```

96
Doc/ComfyApi Normal file
View File

@@ -0,0 +1,96 @@
# ComfyApi - Function Documentation Summary import { api } from "../../scripts/api.js";
## Basic Information
ComfyApi is a class for communication with ComfyUI backend via WebSocket and REST API.
## Main Functions:
### Connection and Initialization
- constructor() - Initializes API, sets host and base path
- init() - Starts WebSocket connection for real-time updates
- #createSocket() - Creates and manages WebSocket connection
### URL Management
- internalURL(route) - Generates URL for internal endpoints
- apiURL(route) - Generates URL for public API endpoints
- fileURL(route) - Generates URL for static files
- fetchApi(route, options) - Performs HTTP requests with automatic user headers
### Event Handling
- addEventListener(type, callback) - Listens for API events (status, executing, progress, etc.)
- removeEventListener(type, callback) - Removes event listeners
- dispatchCustomEvent(type, detail) - Emits custom events
### Queue and Prompt Management
- queuePrompt(number, data) - Adds prompt to execution queue
- getQueue() - Gets current queue state (Running/Pending)
- interrupt() - Interrupts currently executing prompt
- clearItems(type) - Clears queue or history
- deleteItem(type, id) - Removes item from queue or history
### History and Statistics
- getHistory(max_items) - Gets history of executed prompts
- getSystemStats() - Gets system statistics (Python, OS, GPU, etc.)
- getLogs() - Gets system logs
- getRawLogs() - Gets raw logs
- subscribeLogs(enabled) - Enables/disables log subscription
### Model and Resource Management
- getNodeDefs(options) - Gets definitions of available nodes
- getExtensions() - List of installed extensions
- getEmbeddings() - List of available embeddings
- getModelFolders() - List of model folders
- getModels(folder) - List of models in given folder
- viewMetadata(folder, model) - Metadata of specific model
### Workflow Templates
- getWorkflowTemplates() - Gets workflow templates from custom nodes
- getCoreWorkflowTemplates() - Gets core workflow templates
### User Management
- getUserConfig() - Gets user configuration
- createUser(username) - Creates new user
- getSettings() - Gets all user settings
- getSetting(id) - Gets specific setting
- storeSettings(settings) - Saves settings dictionary
- storeSetting(id, value) - Saves single setting
### User Data
- getUserData(file) - Gets user data file
- storeUserData(file, data, options) - Saves user data
- deleteUserData(file) - Deletes user data file
- moveUserData(source, dest) - Moves data file
- listUserDataFullInfo(dir) - Lists files with full information
### Other
- getFolderPaths() - Gets system folder paths
- getCustomNodesI18n() - Gets internationalization data for custom nodes
## Important Properties
- clientId - Client ID from WebSocket
- authToken - Authorization token for ComfyOrg account
- apiKey - API key for ComfyOrg account
- socket - Active WebSocket connection
## WebSocket Event Types
- status - System status
- executing - Currently executing node
- progress - Execution progress
- executed - Node executed
- execution_start/success/error/interrupted/cached - Execution events
- logs - System logs
- b_preview - Image preview (binary)
- reconnecting/reconnected - Connection events

72
Doc/ComfyApp Normal file
View File

@@ -0,0 +1,72 @@
## __Main ComfyApp Functions__ import { app, ComfyApp } from "../../scripts/app.js";
### __Application Management__
- `setup(canvasEl)` - Initializes the application on the page, loads extensions, registers nodes
- `resizeCanvas()` - Adjusts canvas size to window
- `clean()` - Clears application state (node outputs, image previews, errors)
### __Workflow Management__
- `loadGraphData(graphData, clean, restore_view, workflow, options)` - Loads workflow data from JSON
- `loadApiJson(apiData, fileName)` - Loads workflow from API format
- `graphToPrompt(graph, options)` - Converts graph to prompt for execution
- `handleFile(file)` - Handles file loading (PNG, WebP, JSON, MP3, MP4, SVG, etc.)
### __Execution__
- `queuePrompt(number, batchCount, queueNodeIds)` - Queues prompt for execution
- `registerNodes()` - Registers node definitions from backend
- `registerNodeDef(nodeId, nodeDef)` - Registers single node definition
- `refreshComboInNodes()` - Refreshes combo lists in nodes
### __Node Management__
- `registerExtension(extension)` - Registers ComfyUI extension
- `updateVueAppNodeDefs(defs)` - Updates node definitions in Vue app
- `revokePreviews(nodeId)` - Frees memory for node previews
### __Clipboard__
- `copyToClipspace(node)` - Copies node to clipboard
- `pasteFromClipspace(node)` - Pastes data from clipboard to node
### __Position Conversion__
- `clientPosToCanvasPos(pos)` - Converts client position to canvas position
- `canvasPosToClientPos(pos)` - Converts canvas position to client position
### __Error Handling__
- `showErrorOnFileLoad(file)` - Displays file loading error
- `#showMissingNodesError(missingNodeTypes)` - Shows missing nodes error
- `#showMissingModelsError(missingModels, paths)` - Shows missing models error
### __Internal Handlers__
- `#addDropHandler()` - Handles drag and drop of files
- `#addProcessKeyHandler()` - Handles keyboard input
- `#addDrawNodeHandler()` - Modifies node drawing behavior
- `#addApiUpdateHandlers()` - Handles API updates
- `#addConfigureHandler()` - Graph configuration flag
- `#addAfterConfigureHandler()` - Post-configuration handling
### __Deprecated Properties__
Many properties are marked as deprecated and redirect to appropriate stores:
- `lastNodeErrors` → `useExecutionStore().lastNodeErrors`
- `lastExecutionError` → `useExecutionStore().lastExecutionError`
- `runningNodeId` → `useExecutionStore().executingNodeId`
- `shiftDown` → `useWorkspaceStore().shiftDown`
- `widgets` → `useWidgetStore().widgets`
- `extensions` → `useExtensionStore().extensions`
### __Utility Functions__
- `sanitizeNodeName(string)` - Cleans node name from dangerous characters
- `getPreviewFormatParam()` - Returns preview format parameter
- `getRandParam()` - Returns random parameter for refresh
- `isApiJson(data)` - Checks if data is in API JSON format
This application uses Vue and TypeScript composition pattern, where many functionalities are separated into different services and stores (e.g., `useExecutionStore`, `useWorkflowService`, `useExtensionService`, etc.).

75
Doc/LitegraphService Normal file
View File

@@ -0,0 +1,75 @@
LitegraphService Documentation
Main functions of useLitegraphService()
Node Registration and Creation Functions:
registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1)
- Registers node definition in LiteGraph system
- Creates ComfyNode class with inputs, outputs and widgets
- Adds context menu, background drawing and keyboard handling
- Invokes extensions before registration
addNodeOnGraph(nodeDef, options)
- Adds new node to graph at specified position
- By default places node at canvas center
Navigation and View Functions:
getCanvasCenter(): Vector2
- Returns canvas center coordinates accounting for DPI
goToNode(nodeId: NodeId)
- Animates transition to specified node on canvas
resetView()
- Resets canvas view to default settings (scale 1, offset [0,0])
fitView()
- Fits canvas view to show all nodes
Node Handling Functions (internal):
addNodeContextMenuHandler(node)
- Adds context menu with options:
- Open/Copy/Save image (for image nodes)
- Bypass node
- Copy/Paste to Clipspace
- Open in MaskEditor (for image nodes)
addDrawBackgroundHandler(node)
- Adds node background drawing logic
- Handles image, animation and video previews
- Manages thumbnail display
addNodeKeyHandler(node)
- Adds keyboard handling:
- Left/Right arrows: navigate between images
- Escape: close image preview
ComfyNode Class (created by registerNodeDef):
Main methods:
- #addInputs() - adds inputs and widgets to node
- #addOutputs() - adds outputs to node
- configure() - configures node from serialized data
- #setupStrokeStyles() - sets border styles (errors, execution, etc.)
Properties:
- comfyClass - ComfyUI class name
- nodeData - node definition
- Automatic yellow coloring for API nodes

76
Doc/MaskEditor Normal file
View File

@@ -0,0 +1,76 @@
MASKEDITOR.TS FUNCTION DOCUMENTATION
MaskEditorDialog - Main mask editor class
- getInstance() - Singleton pattern, returns editor instance
- show() - Opens the mask editor
- save() - Saves mask to server
- destroy() - Closes and cleans up editor
- isOpened() - Checks if editor is open
CanvasHistory - Change history management
- saveState() - Saves current canvas state
- undo() - Undo last operation
- redo() - Redo undone operation
- clearStates() - Clears history
BrushTool - Brush tool
- setBrushSize(size) - Sets brush size
- setBrushOpacity(opacity) - Sets brush opacity
- setBrushHardness(hardness) - Sets brush hardness
- setBrushType(type) - Sets brush shape (circle/square)
- startDrawing() - Starts drawing
- handleDrawing() - Handles drawing during movement
- drawEnd() - Ends drawing
PaintBucketTool - Fill tool
- floodFill(point) - Fills area with color from point
- setTolerance(tolerance) - Sets color tolerance
- setFillOpacity(opacity) - Sets fill opacity
- invertMask() - Inverts mask
ColorSelectTool - Color selection tool
- fillColorSelection(point) - Selects similar colors
- setTolerance(tolerance) - Sets selection tolerance
- setLivePreview(enabled) - Enables/disables live preview
- setComparisonMethod(method) - Sets color comparison method
- setApplyWholeImage(enabled) - Applies to whole image
- setSelectOpacity(opacity) - Sets selection opacity
UIManager - Interface management
- updateBrushPreview() - Updates brush preview
- setBrushVisibility(visible) - Shows/hides brush
- screenToCanvas(coords) - Converts screen coordinates to canvas
- getMaskColor() - Returns mask color
- setSaveButtonEnabled(enabled) - Enables/disables save button
ToolManager - Tool management
- setTool(tool) - Sets active tool
- getCurrentTool() - Returns active tool
- handlePointerDown/Move/Up() - Handles mouse/touch events
PanAndZoomManager - View management
- zoom(event) - Zooms in/out canvas
- handlePanStart/Move() - Handles canvas panning
- initializeCanvasPanZoom() - Initializes canvas view
- smoothResetView() - Smoothly resets view
MessageBroker - Communication system
- publish(topic, data) - Publishes message
- subscribe(topic, callback) - Subscribes to topic
- pull(topic, data) - Pulls data from topic
- createPullTopic/PushTopic() - Creates communication topics
KeyboardManager - Keyboard handling
- addListeners() - Adds keyboard listeners
- removeListeners() - Removes listeners
- isKeyDown(key) - Checks if key is pressed

13
LAYERFORGE.md Normal file
View File

@@ -0,0 +1,13 @@
**Markdown**
```markdown
[![Top LayerForge Node](https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json)](https://comfy.org)
```
**HTML**
```html
<a href='https://comfy.org'><img alt='Top LayerForge Node' src='https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json'></a>
```

View File

@@ -1,26 +1,24 @@
<h1 align="center">LayerForge Advanced Canvas Editor for ComfyUI 🎨</h1> <h1 align="center">LayerForge Advanced Canvas Editor for ComfyUI 🎨</h1>
<p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p> <p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p>
<p align="center"> <p align="center">
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge">
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Downloads&query=$.downloads&url=https://api.comfy.org/nodes/layerforge&style=for-the-badge"> <a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;">
</a> <img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=%230D2A4A&label=&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json&style=for-the-badge" />
</a>
<a href='https://github.com/Azornes/Comfyui-LayerForge'>
<img alt='GitHub Clones' src='https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github&style=for-the-badge'>
</a>
<a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge"> <a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge">
<img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" /> <img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" />
</a> </a>
<img alt="Python 3.10+" src="https://img.shields.io/badge/-Python_3.10+-4B8BBE?logo=python&logoColor=FFFFFF&style=for-the-badge&logoWidth=20"> <img alt="Python 3.10+" src="https://img.shields.io/badge/-Python_3.10+-4B8BBE?logo=python&logoColor=FFFFFF&style=for-the-badge&logoWidth=20">
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20"> <img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
<a href="https://docs.comfy.org/" target="_blank" rel="noopener noreferrer">
<img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
</a>
</p> </p>
### Why LayerForge? ### Why LayerForge?
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without - **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
@@ -149,7 +147,7 @@ optional feature and requires a model.
> - **Download from**: > - **Download from**:
> >
- [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended) - [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended)
> - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing) - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
> - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`. > - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`.
--- ---

View File

@@ -10,7 +10,12 @@ import threading
import os import os
from tqdm import tqdm from tqdm import tqdm
from torchvision import transforms from torchvision import transforms
from transformers import AutoModelForImageSegmentation, PretrainedConfig try:
from transformers import AutoModelForImageSegmentation, PretrainedConfig
from requests.exceptions import ConnectionError as RequestsConnectionError
TRANSFORMERS_AVAILABLE = True
except ImportError:
TRANSFORMERS_AVAILABLE = False
import torch.nn.functional as F import torch.nn.functional as F
import traceback import traceback
import uuid import uuid
@@ -168,6 +173,7 @@ class CanvasNode:
return { return {
"required": { "required": {
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}), "fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}), "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
"node_id": ("STRING", {"default": "0", "hidden": True}), "node_id": ("STRING", {"default": "0", "hidden": True}),
}, },
@@ -231,7 +237,7 @@ class CanvasNode:
_processing_lock = threading.Lock() _processing_lock = threading.Lock()
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None): def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None):
try: try:
@@ -470,6 +476,70 @@ class CanvasNode:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
async def load_image_from_path_route(request):
try:
data = await request.json()
file_path = data.get('file_path')
if not file_path:
return web.json_response({
'success': False,
'error': 'file_path is required'
}, status=400)
log_info(f"Attempting to load image from path: {file_path}")
# Check if file exists and is accessible
if not os.path.exists(file_path):
log_warn(f"File not found: {file_path}")
return web.json_response({
'success': False,
'error': f'File not found: {file_path}'
}, status=404)
# Check if it's an image file
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
if not file_path.lower().endswith(valid_extensions):
return web.json_response({
'success': False,
'error': f'Invalid image file extension. Supported: {valid_extensions}'
}, status=400)
# Try to load and convert the image
try:
with Image.open(file_path) as img:
# Convert to RGB if necessary
if img.mode != 'RGB':
img = img.convert('RGB')
# Convert to base64
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
log_info(f"Successfully loaded image from path: {file_path}")
return web.json_response({
'success': True,
'image_data': f"data:image/png;base64,{img_str}",
'width': img.width,
'height': img.height
})
except Exception as img_error:
log_error(f"Error processing image file {file_path}: {str(img_error)}")
return web.json_response({
'success': False,
'error': f'Error processing image file: {str(img_error)}'
}, status=500)
except Exception as e:
log_error(f"Error in load_image_from_path_route: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def store_image(self, image_data): def store_image(self, image_data):
if isinstance(image_data, str) and image_data.startswith('data:image'): if isinstance(image_data, str) and image_data.startswith('data:image'):
@@ -647,25 +717,31 @@ _matting_lock = None
async def matting(request): async def matting(request):
global _matting_lock global _matting_lock
if not TRANSFORMERS_AVAILABLE:
log_error("Matting request failed: 'transformers' library is not installed.")
return web.json_response({
"error": "Dependency Not Found",
"details": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
}, status=400)
if _matting_lock is not None: if _matting_lock is not None:
log_warn("Matting already in progress, rejecting request") log_warn("Matting already in progress, rejecting request")
return web.json_response({ return web.json_response({
"error": "Another matting operation is in progress", "error": "Another matting operation is in progress",
"details": "Please wait for the current operation to complete" "details": "Please wait for the current operation to complete"
}, status=429) # 429 Too Many Requests }, status=429)
_matting_lock = True _matting_lock = True
try: try:
log_info("Received matting request") log_info("Received matting request")
data = await request.json() data = await request.json()
matting = BiRefNetMatting() matting_instance = BiRefNetMatting()
image_tensor, original_alpha = convert_base64_to_tensor(data["image"]) image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
log_debug(f"Input image shape: {image_tensor.shape}") log_debug(f"Input image shape: {image_tensor.shape}")
matted_image, alpha_mask = matting.execute( matted_image, alpha_mask = matting_instance.execute(
image_tensor, image_tensor,
"BiRefNet/model.safetensors", "BiRefNet/model.safetensors",
threshold=data.get("threshold", 0.5), threshold=data.get("threshold", 0.5),
@@ -680,14 +756,26 @@ async def matting(request):
"alpha_mask": result_mask "alpha_mask": result_mask
}) })
except Exception as e: except RequestsConnectionError as e:
log_exception(f"Error in matting endpoint: {str(e)}") log_error(f"Connection error during matting model download: {e}")
return web.json_response({ return web.json_response({
"error": str(e), "error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
}, status=400)
except Exception as e:
log_exception(f"Error in matting endpoint: {e}")
# Check for offline error message from Hugging Face
if "Offline mode is enabled" in str(e) or "Can't load 'ZhengPeng7/BiRefNet' offline" in str(e):
return web.json_response({
"error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection and ensure you are not in offline mode."
}, status=400)
return web.json_response({
"error": "An unexpected error occurred",
"details": traceback.format_exc() "details": traceback.format_exc()
}, status=500) }, status=500)
finally: finally:
_matting_lock = None _matting_lock = None
log_debug("Matting lock released") log_debug("Matting lock released")

View File

@@ -0,0 +1,365 @@
{
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
"revision": 0,
"last_node_id": 705,
"last_link_id": 1497,
"nodes": [
{
"id": 368,
"type": "Mask To Image (mtb)",
"pos": [
-1913.9735107421875,
-3351.5126953125
],
"size": [
210,
130
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "mask",
"type": "MASK",
"link": 1496
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
612
]
}
],
"properties": {
"cnr_id": "comfy-mtb",
"ver": "7e36007933f42c29cca270ae55e0e6866e323633",
"Node name for S&R": "Mask To Image (mtb)",
"widget_ue_connectable": {}
},
"widgets_values": [
"#ff0000",
"#000000",
false
]
},
{
"id": 442,
"type": "JoinImageWithAlpha",
"pos": [
-1907.2977294921875,
-3180.562744140625
],
"size": [
176.86483764648438,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1494
},
{
"name": "alpha",
"type": "MASK",
"link": 1497
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1236,
1465
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "JoinImageWithAlpha",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 369,
"type": "PreviewImage",
"pos": [
-1699.1021728515625,
-3355.60498046875
],
"size": [
660.91162109375,
400.2092590332031
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 612
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 606,
"type": "PreviewImage",
"pos": [
-1911.126708984375,
-2916.072998046875
],
"size": [
551.7399291992188,
546.8018798828125
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1495
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 603,
"type": "PreviewImage",
"pos": [
-1344.1650390625,
-2915.117919921875
],
"size": [
601.4136962890625,
527.1531372070312
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1236
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 680,
"type": "SaveImage",
"pos": [
-1025.9984130859375,
-3357.975341796875
],
"size": [
278.8309020996094,
395.84002685546875
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1465
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "SaveImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 701,
"type": "MarkdownNote",
"pos": [
-3330.08984375,
-3347.998291015625
],
"size": [
347.055419921875,
217.8630828857422
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Known Issue",
"properties": {
"widget_ue_connectable": {}
},
"widgets_values": [
"### `node_id` not auto-filled → black output\n\nIn some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.\nAs a result, the node may produce a **completely black image** or not work at all.\n\n**Workaround:**\n\n* Search node ID in ComfyUI settings.\n* In NodesMap check \"Enable node ID display\"\n* Manually enter the correct `node_id` (match the ID shown in the UI).\n\n⚠ This is a known issue and not yet fixed.\nPlease follow the steps above if your output is black or broken."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 697,
"type": "CanvasNode",
"pos": [
-2968.572998046875,
-3347.89306640625
],
"size": [
1044.9053955078125,
980.680908203125
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1494,
1495
]
},
{
"name": "mask",
"type": "MASK",
"links": [
1496,
1497
]
}
],
"properties": {
"cnr_id": "Comfyui-Ycanvas",
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
"Node name for S&R": "CanvasNode",
"widget_ue_connectable": {}
},
"widgets_values": [
true,
17,
"697",
""
]
}
],
"links": [
[
612,
368,
0,
369,
0,
"IMAGE"
],
[
1236,
442,
0,
603,
0,
"IMAGE"
],
[
1465,
442,
0,
680,
0,
"IMAGE"
],
[
1494,
697,
0,
442,
0,
"IMAGE"
],
[
1495,
697,
0,
606,
0,
"IMAGE"
],
[
1496,
697,
1,
368,
0,
"MASK"
],
[
1497,
697,
1,
442,
1,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.7972024500000005,
"offset": [
3957.401300495613,
3455.1487103849176
]
},
"ue_links": [],
"links_added_by_ue": [],
"frontendVersion": "1.23.4",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -46,7 +46,7 @@ export class CanvasIO {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true); return Promise.resolve(true);
} }
await this.canvas.saveStateToDB(true); await this.canvas.canvasState.saveStateToDB(true);
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50; const delay = (nodeId % 10) * 50;
if (delay > 0) { if (delay > 0) {
@@ -102,7 +102,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d'); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -279,7 +279,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d'); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -374,7 +374,7 @@ export class CanvasIO {
this.canvas.height / inputImage.height * 0.8 this.canvas.height / inputImage.height * 0.8
); );
const layer = await this.canvas.addLayerWithImage(image, { const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2, x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2, y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale, width: inputImage.width * scale,
@@ -403,7 +403,7 @@ export class CanvasIO {
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = tensor.width; canvas.width = tensor.width;
canvas.height = tensor.height; canvas.height = tensor.height;
@@ -611,7 +611,7 @@ export class CanvasIO {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = imageData.width; canvas.width = imageData.width;
canvas.height = imageData.height; canvas.height = imageData.height;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
const img = new Image(); const img = new Image();
@@ -684,7 +684,7 @@ export class CanvasIO {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.width = img.width;
tempCanvas.height = img.height; tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(img, 0, 0); tempCtx.drawImage(img, 0, 0);
@@ -693,7 +693,7 @@ export class CanvasIO {
const maskCanvas = document.createElement('canvas'); const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width; maskCanvas.width = img.width;
maskCanvas.height = img.height; maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.drawImage(mask, 0, 0); maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height); const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
@@ -744,7 +744,7 @@ export class CanvasIO {
img.src = result.image_data; img.src = result.image_data;
}); });
await this.canvas.addLayerWithImage(img, { await this.canvas.canvasLayers.addLayerWithImage(img, {
x: 0, x: 0,
y: 0, y: 0,
width: this.canvas.width, width: this.canvas.width,

View File

@@ -19,6 +19,7 @@ export class CanvasInteractions {
hasClonedInDrag: false, hasClonedInDrag: false,
lastClickTime: 0, lastClickTime: 0,
transformingLayer: null, transformingLayer: null,
keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
this.interaction.canvasResizeRect = null; this.interaction.canvasResizeRect = null;
@@ -34,6 +35,8 @@ export class CanvasInteractions {
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this)); this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true; this.canvas.isMouseOver = true;
this.handleMouseEnter(e); this.handleMouseEnter(e);
@@ -42,6 +45,13 @@ export class CanvasInteractions {
this.canvas.isMouseOver = false; this.canvas.isMouseOver = false;
this.handleMouseLeave(e); 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));
} }
resetInteractionState() { resetInteractionState() {
@@ -60,73 +70,66 @@ export class CanvasInteractions {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) { // --- Ostateczna, poprawna kolejność sprawdzania ---
if (e.button === 1) {
this.startPanning(e);
} else {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
}
this.canvas.render();
return;
}
const currentTime = Date.now(); // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) { if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords); this.startCanvasMove(worldCoords);
this.canvas.render(); return;
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
return;
}
// 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault();
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
}
return;
}
if (e.button !== 0) { // Środkowy przycisk
this.startPanning(e);
return; return;
} }
if (currentTime - this.interaction.lastClickTime < 300) { // 3. Interakcje z elementami na płótnie (lewy przycisk)
this.canvas.updateSelection([]); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
this.canvas.selectedLayer = null;
this.resetInteractionState();
this.canvas.render();
return;
}
this.interaction.lastClickTime = currentTime;
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return; return;
} }
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) { if (clickedLayerResult) {
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) { this.prepareForDrag(clickedLayerResult.layer, worldCoords);
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
return;
}
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
return; return;
} }
if (e.shiftKey) {
this.startCanvasResize(worldCoords); // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
} else { this.startPanningOrClearSelection(e);
this.startPanning(e);
}
this.canvas.render();
} }
handleMouseMove(e) { handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e); this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
this.canvas.lastMousePosition = worldCoords;
// Sprawdź, czy rozpocząć przeciąganie
if (this.canvas.maskTool.isActive) { if (this.interaction.mode === 'potential-drag') {
if (this.interaction.mode === 'panning') { const dx = worldCoords.x - this.interaction.dragStart.x;
this.panViewport(e); const dy = worldCoords.y - this.interaction.dragStart.y;
return; if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
this.interaction.mode = 'dragging';
this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
});
} }
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
if (this.canvas.maskTool.isDrawing) {
this.canvas.render();
}
return;
} }
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'panning': case 'panning':
this.panViewport(e); this.panViewport(e);
@@ -153,31 +156,24 @@ export class CanvasInteractions {
} }
handleMouseUp(e) { handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
if (this.interaction.mode === 'panning') {
this.resetInteractionState();
} else {
this.canvas.maskTool.handleMouseUp(viewCoords);
}
this.canvas.render();
return;
}
const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning';
if (this.interaction.mode === 'resizingCanvas') { if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize(); this.finalizeCanvasResize();
} else if (this.interaction.mode === 'movingCanvas') { }
if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove(); this.finalizeCanvasMove();
} }
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
if (stateChangingInteraction || duplicatedInDrag) {
this.canvas.saveState();
this.canvas.canvasState.saveStateToDB(true);
}
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
if (interactionEnded) {
this.canvas.saveState();
this.canvas.saveStateToDB(true);
}
} }
handleMouseLeave(e) { handleMouseLeave(e) {
@@ -194,6 +190,11 @@ export class CanvasInteractions {
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas");
}
} }
handleMouseEnter(e) { handleMouseEnter(e) {
@@ -202,6 +203,11 @@ export class CanvasInteractions {
} }
} }
handleContextMenu(e) {
e.preventDefault();
}
handleWheel(e) { handleWheel(e) {
e.preventDefault(); e.preventDefault();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -218,10 +224,22 @@ export class CanvasInteractions {
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.selectedLayer) { } else if (this.canvas.selectedLayer) {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
this.canvas.selectedLayers.forEach(layer => { this.canvas.selectedLayers.forEach(layer => {
if (e.shiftKey) { if (e.shiftKey) {
layer.rotation += rotationStep; // Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
if (e.ctrlKey) {
const snapAngle = 5;
if (direction > 0) { // Obrót w górę/prawo
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
} else { // Obrót w dół/lewo
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
}
} else {
// Stara funkcjonalność: Shift + Kółko obraca o stały krok
layer.rotation += rotationStep;
}
} else { } else {
const oldWidth = layer.width; const oldWidth = layer.width;
const oldHeight = layer.height; const oldHeight = layer.height;
@@ -280,115 +298,81 @@ export class CanvasInteractions {
} }
this.canvas.render(); this.canvas.render();
if (!this.canvas.maskTool.isActive) { if (!this.canvas.maskTool.isActive) {
this.canvas.saveState(true); this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu
} }
} }
handleKeyDown(e) { handleKeyDown(e) {
if (this.canvas.maskTool.isActive) {
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.redo();
} else {
this.canvas.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.redo();
return;
}
}
return;
}
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true; this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
if (e.ctrlKey) { // Globalne skróty (Undo/Redo/Copy/Paste)
if (e.key.toLowerCase() === 'z') { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); let handled = true;
e.stopPropagation(); switch (e.key.toLowerCase()) {
if (e.shiftKey) { case 'z':
if (e.shiftKey) {
this.canvas.redo();
} else {
this.canvas.undo();
}
break;
case 'y':
this.canvas.redo(); this.canvas.redo();
} else { break;
this.canvas.undo(); case 'c':
} if (this.canvas.selectedLayers.length > 0) {
return; this.canvas.canvasLayers.copySelectedLayers();
}
break;
case 'v':
this.canvas.canvasLayers.handlePaste('mouse');
break;
default:
handled = false;
break;
} }
if (e.key.toLowerCase() === 'y') { if (handled) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.redo();
return;
}
if (e.key.toLowerCase() === 'c') {
if (this.canvas.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.copySelectedLayers();
}
return;
}
if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.canvas.handlePaste('mouse');
return; return;
} }
} }
if (this.canvas.selectedLayer) { // Skróty kontekstowe (zależne od zaznaczenia)
if (e.key === 'Delete') { if (this.canvas.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.canvas.selectedLayers.includes(l));
this.canvas.updateSelection([]);
this.canvas.render();
return;
}
const step = e.shiftKey ? 10 : 1; const step = e.shiftKey ? 10 : 1;
let needsRender = false; let needsRender = false;
switch (e.code) {
case 'ArrowLeft': // Używamy e.code dla spójności i niezależności od układu klawiatury
case 'ArrowRight': const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
case 'ArrowUp': if (movementKeys.includes(e.code)) {
case 'ArrowDown': e.preventDefault();
case 'BracketLeft': e.stopPropagation();
case 'BracketRight': this.interaction.keyMovementInProgress = true;
e.preventDefault();
e.stopPropagation();
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step); if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step); if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step);
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step); if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step); if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step);
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step); if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step); if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step);
needsRender = true; needsRender = true;
break;
} }
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
e.stopPropagation();
this.canvas.removeSelectedLayers();
return;
}
if (needsRender) { if (needsRender) {
this.canvas.render(); this.canvas.render();
this.canvas.saveState();
} }
} }
} }
@@ -396,10 +380,16 @@ export class CanvasInteractions {
handleKeyUp(e) { handleKeyUp(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = false; if (e.key === 'Control') this.interaction.isCtrlPressed = false;
if (e.key === 'Alt') this.interaction.isAltPressed = false; if (e.key === 'Alt') this.interaction.isAltPressed = false;
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
this.interaction.keyMovementInProgress = false;
}
} }
updateCursor(worldCoords) { updateCursor(worldCoords) {
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
@@ -409,7 +399,7 @@ export class CanvasInteractions {
'rot': 'grab' 'rot': 'grab'
}; };
this.canvas.canvas.style.cursor = cursorMap[handleName]; this.canvas.canvas.style.cursor = cursorMap[handleName];
} else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) { } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
this.canvas.canvas.style.cursor = 'move'; this.canvas.canvas.style.cursor = 'move';
} else { } else {
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
@@ -432,7 +422,7 @@ export class CanvasInteractions {
} else { } else {
this.interaction.mode = 'resizing'; this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle; this.interaction.resizeHandle = handle;
const handles = this.canvas.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey = { const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
@@ -442,31 +432,34 @@ export class CanvasInteractions {
this.canvas.render(); this.canvas.render();
} }
startLayerDrag(layer, worldCoords) { prepareForDrag(layer, worldCoords) {
this.interaction.mode = 'dragging'; // Zaktualizuj zaznaczenie, ale nie zapisuj stanu
this.interaction.dragStart = {...worldCoords};
let currentSelection = [...this.canvas.selectedLayers];
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const index = currentSelection.indexOf(layer); const index = this.canvas.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
currentSelection.push(layer); this.canvas.updateSelection([...this.canvas.selectedLayers, layer]);
} else { } else {
currentSelection.splice(index, 1); const newSelection = this.canvas.selectedLayers.filter(l => l !== layer);
this.canvas.updateSelection(newSelection);
} }
} else { } else {
if (!currentSelection.includes(layer)) { if (!this.canvas.selectedLayers.includes(layer)) {
currentSelection = [layer]; this.canvas.updateSelection([layer]);
} }
} }
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords};
}
this.canvas.updateSelection(currentSelection); startPanningOrClearSelection(e) {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
this.originalLayerPositions.clear(); // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
this.canvas.selectedLayers.forEach(l => { if (!this.interaction.isCtrlPressed) {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.canvas.updateSelection([]);
}); }
this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY};
} }
startCanvasResize(worldCoords) { startCanvasResize(worldCoords) {
@@ -525,6 +518,7 @@ export class CanvasInteractions {
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
this.canvas.render(); this.canvas.render();
this.canvas.saveState();
} }
startPanning(e) { startPanning(e) {
@@ -546,19 +540,12 @@ export class CanvasInteractions {
dragLayers(worldCoords) { dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) {
const newLayers = []; // Scentralizowana logika duplikowania
this.canvas.selectedLayers.forEach(layer => { const newLayers = this.canvas.duplicateSelectedLayers();
const newLayer = {
...layer, // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
zIndex: this.canvas.layers.length,
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvas.updateSelection(newLayers);
this.canvas.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null;
this.originalLayerPositions.clear(); this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => { newLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.originalLayerPositions.set(l, {x: l.x, y: l.y});
}); });
this.interaction.hasClonedInDrag = true; this.interaction.hasClonedInDrag = true;
@@ -712,4 +699,130 @@ export class CanvasInteractions {
this.canvas.viewport.y -= rectY; this.canvas.viewport.y -= rectY;
} }
} }
handleDragOver(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
e.dataTransfer.dropEffect = 'copy';
}
handleDragEnter(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget)) {
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
}
}
async handleDrop(e) {
e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error);
}
} else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
}
}
}
async loadDroppedImageFile(file, worldCoords) {
const reader = new FileReader();
reader.onload = async (e) => {
const img = new Image();
img.onload = async () => {
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.addLayer(img, {}, addMode);
};
img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`);
};
img.src = e.target.result;
};
reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`);
};
reader.readAsDataURL(file);
}
async handlePasteEvent(e) {
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");
return;
}
log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault();
e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return;
}
const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const file = item.getAsFile();
if (file) {
log.info("Found direct image data in paste event");
const reader = new FileReader();
reader.onload = async (event) => {
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
return;
}
}
}
}
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
}
} }

File diff suppressed because it is too large Load Diff

676
js/CanvasLayersPanel.js Normal file
View File

@@ -0,0 +1,676 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel {
constructor(canvas) {
this.canvas = canvas;
this.container = null;
this.layersContainer = null;
this.draggedElements = [];
this.dragInsertionLine = null;
this.isMultiSelecting = false;
this.lastSelectedIndex = -1;
// Binding metod dla event handlerów
this.handleLayerClick = this.handleLayerClick.bind(this);
this.handleDragStart = this.handleDragStart.bind(this);
this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this);
log.info('CanvasLayersPanel initialized');
}
/**
* Tworzy struktur&ecirc; HTML panelu warstw
*/
createPanelStructure() {
// Główny kontener panelu
this.container = document.createElement('div');
this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = `
<div class="layers-panel-header">
<span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
</div>
</div>
<div class="layers-container" id="layers-container">
<!-- Lista warstw będzie renderowana tutaj -->
</div>
`;
this.layersContainer = this.container.querySelector('#layers-container');
// Dodanie stylów CSS
this.injectStyles();
// Setup event listeners dla przycisków
this.setupControlButtons();
// 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();
}
});
log.debug('Panel structure created');
return this.container;
}
/**
* Dodaje style CSS do panelu
*/
injectStyles() {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
/**
* Konfiguruje event listenery dla przycisków kontrolnych
*/
setupControlButtons() {
const deleteBtn = this.container.querySelector('#delete-layer-btn');
deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked');
this.deleteSelectedLayers();
});
}
/**
* Renderuje listę warstw
*/
renderLayers() {
if (!this.layersContainer) {
log.warn('Layers container not initialized');
return;
}
// Wyczyść istniejącą zawartość
this.layersContainer.innerHTML = '';
// Usuń linię wstawiania jeśli istnieje
this.removeDragInsertionLine();
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
sortedLayers.forEach((layer, index) => {
const layerElement = this.createLayerElement(layer, index);
this.layersContainer.appendChild(layerElement);
});
log.debug(`Rendered ${sortedLayers.length} layers`);
}
/**
* Tworzy element HTML dla pojedynczej warstwy
*/
createLayerElement(layer, index) {
const layerRow = document.createElement('div');
layerRow.className = 'layer-row';
layerRow.draggable = true;
layerRow.dataset.layerIndex = index;
// Sprawdź czy warstwa jest zaznaczona
const isSelected = this.canvas.selectedLayers.includes(layer);
if (isSelected) {
layerRow.classList.add('selected');
}
// Ustawienie domyślnych właściwości jeśli nie istnieją
if (!layer.name) {
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
} else {
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
layer.name = this.ensureUniqueName(layer.name, layer);
}
layerRow.innerHTML = `
<div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`;
// Wygeneruj miniaturkę
this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail'));
// Event listenery
this.setupLayerEventListeners(layerRow, layer, index);
return layerRow;
}
/**
* Generuje miniaturkę warstwy
*/
generateThumbnail(layer, thumbnailContainer) {
if (!layer.image) {
thumbnailContainer.style.background = '#4a4a4a';
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = 48;
canvas.height = 48;
// Oblicz skalę zachowując proporcje
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale;
// Wycentruj obraz
const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2;
// Narysuj obraz z wyższą jakością
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
thumbnailContainer.appendChild(canvas);
}
/**
* Konfiguruje event listenery dla elementu warstwy
*/
setupLayerEventListeners(layerRow, layer, index) {
// Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku
layerRow.addEventListener('mousedown', (e) => {
// Ignoruj, jeśli edytujemy nazwę
const nameElement = layerRow.querySelector('.layer-name');
if (nameElement && nameElement.classList.contains('editing')) {
return;
}
this.handleLayerClick(e, layer, index);
});
// Double click handler - edycja nazwy
layerRow.addEventListener('dblclick', (e) => {
e.preventDefault();
e.stopPropagation();
const nameElement = layerRow.querySelector('.layer-name');
this.startEditingLayerName(nameElement, layer);
});
// Drag handlers
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver);
layerRow.addEventListener('dragend', this.handleDragEnd);
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index));
}
/**
* Obsługuje kliknięcie na warstwę, aktualizując stan bez pełnego renderowania.
*/
handleLayerClick(e, layer, index) {
const isCtrlPressed = e.ctrlKey || e.metaKey;
const isShiftPressed = e.shiftKey;
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.selectedLayers.length}`);
}
/**
* Rozpoczyna edycję nazwy warstwy
*/
startEditingLayerName(nameElement, layer) {
const currentName = layer.name;
nameElement.classList.add('editing');
const input = document.createElement('input');
input.type = 'text';
input.value = currentName;
input.style.width = '100%';
nameElement.innerHTML = '';
nameElement.appendChild(input);
input.focus();
input.select();
const finishEditing = () => {
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
newName = this.ensureUniqueName(newName, layer);
layer.name = newName;
nameElement.classList.remove('editing');
nameElement.textContent = newName;
this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`);
};
input.addEventListener('blur', finishEditing);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
finishEditing();
} else if (e.key === 'Escape') {
nameElement.classList.remove('editing');
nameElement.textContent = currentName;
}
});
}
/**
* Zapewnia unikalność nazwy warstwy
*/
ensureUniqueName(proposedName, currentLayer) {
const existingNames = this.canvas.layers
.filter(layer => layer !== currentLayer)
.map(layer => layer.name);
if (!existingNames.includes(proposedName)) {
return proposedName;
}
// Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber;
if (match) {
baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1;
} else {
baseName = proposedName;
startNumber = 1;
}
// Znajdź pierwszą dostępną numerację
let counter = startNumber;
let uniqueName;
do {
uniqueName = `${baseName} (${counter})`;
counter++;
} while (existingNames.includes(uniqueName));
return uniqueName;
}
/**
* Usuwa zaznaczone warstwy
*/
deleteSelectedLayers() {
if (this.canvas.selectedLayers.length === 0) {
log.debug('No layers selected for deletion');
return;
}
log.info(`Deleting ${this.canvas.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers();
this.renderLayers();
}
/**
* Rozpoczyna przeciąganie warstwy
*/
handleDragStart(e, layer, index) {
// Sprawdź czy jakakolwiek warstwa jest w trybie edycji
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
if (editingElement) {
e.preventDefault();
return;
}
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
if (!this.canvas.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]);
this.renderLayers();
}
this.draggedElements = [...this.canvas.selectedLayers];
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard
// Dodaj klasę dragging do przeciąganych elementów
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
if (this.draggedElements.includes(sortedLayers[idx])) {
row.classList.add('dragging');
}
});
log.debug(`Started dragging ${this.draggedElements.length} layers`);
}
/**
* Obsługuje przeciąganie nad warstwą
*/
handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const layerRow = e.currentTarget;
const rect = layerRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint;
this.showDragInsertionLine(layerRow, isUpperHalf);
}
/**
* Pokazuje linię wskaźnika wstawiania
*/
showDragInsertionLine(targetRow, isUpperHalf) {
this.removeDragInsertionLine();
const line = document.createElement('div');
line.className = 'drag-insertion-line';
if (isUpperHalf) {
line.style.top = '-1px';
} else {
line.style.bottom = '-1px';
}
targetRow.style.position = 'relative';
targetRow.appendChild(line);
this.dragInsertionLine = line;
}
/**
* Usuwa linię wskaźnika wstawiania
*/
removeDragInsertionLine() {
if (this.dragInsertionLine) {
this.dragInsertionLine.remove();
this.dragInsertionLine = null;
}
}
/**
* Obsługuje upuszczenie warstwy
*/
handleDrop(e, targetIndex) {
e.preventDefault();
this.removeDragInsertionLine();
if (this.draggedElements.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks
let insertIndex = targetIndex;
if (!isUpperHalf) {
insertIndex = targetIndex + 1;
}
// Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
}
/**
* Kończy przeciąganie
*/
handleDragEnd(e) {
this.removeDragInsertionLine();
// Usuń klasę dragging ze wszystkich elementów
this.layersContainer.querySelectorAll('.layer-row').forEach(row => {
row.classList.remove('dragging');
});
this.draggedElements = [];
}
/**
* Aktualizuje panel gdy zmienią się warstwy
*/
onLayersChanged() {
this.renderLayers();
}
/**
* Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania.
*/
updateSelectionAppearance() {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
layerRows.forEach((row, index) => {
const layer = sortedLayers[index];
if (this.canvas.selectedLayers.includes(layer)) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
});
}
/**
* Aktualizuje panel gdy zmienią się warstwy (np. dodanie, usunięcie, zmiana kolejności)
* To jest jedyne miejsce, gdzie powinniśmy w pełni renderować panel.
*/
onLayersChanged() {
this.renderLayers();
}
/**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/
onSelectionChanged() {
this.updateSelectionAppearance();
}
/**
* Niszczy panel i czyści event listenery
*/
destroy() {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.container = null;
this.layersContainer = null;
this.draggedElements = [];
this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed');
}
}

View File

@@ -301,7 +301,7 @@ export class CanvasRenderer {
ctx.moveTo(0, -layer.height / 2); ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke(); ctx.stroke();
const handles = this.canvas.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; ctx.lineWidth = 1 / this.canvas.viewport.zoom;

View File

@@ -16,6 +16,25 @@ export class CanvasState {
this.saveTimeout = null; this.saveTimeout = null;
this.lastSavedStateSignature = null; this.lastSavedStateSignature = null;
this._loadInProgress = null; this._loadInProgress = null;
// Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami
try {
// new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
log.info("State saver worker initialized successfully.");
this.stateSaverWorker.onmessage = (e) => {
log.info("Message from state saver worker:", e.data);
};
this.stateSaverWorker.onerror = (e) => {
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
// Zapobiegaj dalszym próbom, jeśli worker nie działa
this.stateSaverWorker = null;
};
} catch (e) {
log.error("Failed to initialize state saver worker:", e);
this.stateSaverWorker = null;
}
} }
@@ -182,46 +201,35 @@ export class CanvasState {
img.src = imageSrc; img.src = imageSrc;
} }
async saveStateToDB(immediate = false) { async saveStateToDB() {
log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id);
if (!this.canvas.node.id) { if (!this.canvas.node.id) {
log.error("Node ID is not available for saving state to DB."); log.error("Node ID is not available for saving state to DB.");
return; return;
} }
const currentStateSignature = getStateSignature(this.canvas.layers); log.info("Preparing state to be sent to worker...");
if (this.lastSavedStateSignature === currentStateSignature) { const state = {
log.debug("State unchanged, skipping save to IndexedDB."); layers: await this._prepareLayers(),
viewport: this.canvas.viewport,
width: this.canvas.width,
height: this.canvas.height,
};
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping.");
return; return;
} }
if (this.saveTimeout) { if (this.stateSaverWorker) {
clearTimeout(this.saveTimeout); log.info("Posting state to worker for background saving.");
} this.stateSaverWorker.postMessage({
nodeId: this.canvas.node.id,
const saveFunction = withErrorHandling(async () => { state: state
const state = { });
layers: await this._prepareLayers(),
viewport: this.canvas.viewport,
width: this.canvas.width,
height: this.canvas.height,
};
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping save to IndexedDB.");
return;
}
await setCanvasState(this.canvas.node.id, state);
log.info("Canvas state saved to IndexedDB.");
this.lastSavedStateSignature = currentStateSignature;
}, 'CanvasState.saveStateToDB');
if (immediate) {
await saveFunction();
} else { } else {
this.saveTimeout = setTimeout(saveFunction, 1000); log.warn("State saver worker not available. Saving on main thread.");
await setCanvasState(this.canvas.node.id, state);
} }
} }
@@ -263,14 +271,15 @@ export class CanvasState {
} }
const currentState = cloneLayers(this.canvas.layers); const currentState = cloneLayers(this.canvas.layers);
const currentStateSignature = getStateSignature(currentState);
if (this.layersUndoStack.length > 0) { if (this.layersUndoStack.length > 0) {
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
if (getStateSignature(currentState) === getStateSignature(lastState)) { if (getStateSignature(lastState) === currentStateSignature) {
return; return;
} }
} }
this.layersUndoStack.push(currentState); this.layersUndoStack.push(currentState);
if (this.layersUndoStack.length > this.historyLimit) { if (this.layersUndoStack.length > this.historyLimit) {
@@ -278,7 +287,11 @@ export class CanvasState {
} }
this.layersRedoStack = []; this.layersRedoStack = [];
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500);
// Użyj debouncingu, aby zapobiec zbyt częstym zapisom
if (!this._debouncedSave) {
this._debouncedSave = debounce(() => this.saveStateToDB(), 1000);
}
this._debouncedSave(); this._debouncedSave();
} }
@@ -292,7 +305,7 @@ export class CanvasState {
const clonedCanvas = document.createElement('canvas'); const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = maskCanvas.width; clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height; clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d'); const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
clonedCtx.drawImage(maskCanvas, 0, 0); clonedCtx.drawImage(maskCanvas, 0, 0);
this.maskUndoStack.push(clonedCanvas); this.maskUndoStack.push(clonedCanvas);
@@ -352,7 +365,7 @@ export class CanvasState {
if (this.maskUndoStack.length > 0) { if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0); maskCtx.drawImage(prevState, 0, 0);
@@ -368,7 +381,7 @@ export class CanvasState {
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
this.maskUndoStack.push(nextState); this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0); maskCtx.drawImage(nextState, 0, 0);

View File

@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
border-radius: 6px; border-radius: 6px;
} }
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
}
.painter-separator { .painter-separator {
width: 1px; width: 1px;
height: 28px; height: 28px;
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
} }
.painter-tooltip table td:first-child { .painter-tooltip table td:first-child {
width: 45%; width: auto;
white-space: nowrap; white-space: nowrap;
min-width: fit-content;
} }
.painter-tooltip table td:last-child { .painter-tooltip table td:last-child {
width: 55%; width: auto;
} }
.painter-tooltip table tr:nth-child(odd) td { .painter-tooltip table tr:nth-child(odd) td {
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
z-index: 9998; z-index: 111;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
flex-direction: column; flex-direction: column;
position: relative; position: relative;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -399,7 +429,7 @@ async function createCanvasWidget(node, widget, app) {
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr> <tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr> <tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr> <tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Double Click (background)</kbd></td><td>Deselect all layers</td></tr> <tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table> </table>
<h4>Clipboard & I/O</h4> <h4>Clipboard & I/O</h4>
@@ -414,10 +444,11 @@ async function createCanvasWidget(node, widget, app) {
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr> <tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr> <tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr> <tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
<tr><td><kbd>Shift + Click</kbd></td><td>Show blend mode & opacity menu</td></tr> <tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr> <tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr> <tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5°</td></tr> <tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr> <tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr> <tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr> <tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
@@ -447,6 +478,41 @@ async function createCanvasWidget(node, widget, app) {
`; `;
document.body.appendChild(helpTooltip); document.body.appendChild(helpTooltip);
// Helper function for tooltip positioning
const showTooltip = (buttonElement, content) => {
helpTooltip.innerHTML = content;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = buttonElement.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
};
const hideTooltip = () => {
helpTooltip.style.display = 'none';
};
const controlPanel = $el("div.painterControlPanel", {}, [ const controlPanel = $el("div.painterControlPanel", {}, [
$el("div.controls.painter-controls", { $el("div.controls.painter-controls", {
style: { style: {
@@ -478,50 +544,10 @@ async function createCanvasWidget(node, widget, app) {
fontWeight: "bold", fontWeight: "bold",
}, },
onmouseenter: (e) => { onmouseenter: (e) => {
if (canvas.maskTool.isActive) { const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
helpTooltip.innerHTML = maskShortcuts; showTooltip(e.target, content);
} else {
helpTooltip.innerHTML = standardShortcuts;
}
// Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Obliczamy pozycję
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
// Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
// Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu
if (top + tooltipRect.height > viewportHeight) {
// Wyświetlamy nad przyciskiem zamiast pod
top = buttonRect.top - tooltipRect.height - 5;
}
// Upewniamy się, że tooltip nie wychodzi poza lewy brzeg
if (left < 10) left = 10;
// Upewniamy się, że tooltip nie wychodzi poza górny brzeg
if (top < 10) top = 10;
// Ustawiamy finalną pozycję i pokazujemy tooltip
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
}, },
onmouseleave: () => { onmouseleave: hideTooltip
helpTooltip.style.display = 'none';
}
}), }),
$el("button.painter-button.primary", { $el("button.painter-button.primary", {
textContent: "Add Image", textContent: "Add Image",
@@ -539,7 +565,7 @@ async function createCanvasWidget(node, widget, app) {
reader.onload = (event) => { reader.onload = (event) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
canvas.addLayer(img, addMode); canvas.addLayer(img, {}, addMode);
}; };
img.src = event.target.result; img.src = event.target.result;
}; };
@@ -552,17 +578,89 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.primary", { $el("button.painter-button.primary", {
textContent: "Import Input", textContent: "Import Input",
title: "Import image from another node", title: "Import image from another node",
onclick: () => canvas.importLatestImage() onclick: () => canvas.canvasIO.importLatestImage()
}),
$el("button.painter-button.primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
canvas.handlePaste(addMode);
}
}), }),
$el("div.painter-clipboard-group", {}, [
$el("button.painter-button.primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
canvas.canvasLayers.handlePaste(addMode);
}
}),
$el("button.painter-button", {
id: `clipboard-toggle-${node.id}`,
textContent: "📋 System",
title: "Toggle clipboard source: System Clipboard",
style: {
minWidth: "100px",
fontSize: "11px",
backgroundColor: "#4a4a4a"
},
onclick: (e) => {
const button = e.target;
if (canvas.canvasLayers.clipboardPreference === 'system') {
canvas.canvasLayers.clipboardPreference = 'clipspace';
button.textContent = "📋 Clipspace";
button.title = "Toggle clipboard source: ComfyUI Clipspace";
button.style.backgroundColor = "#4a6cd4";
} else {
canvas.canvasLayers.clipboardPreference = 'system';
button.textContent = "📋 System";
button.title = "Toggle clipboard source: System Clipboard";
button.style.backgroundColor = "#4a4a4a";
}
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
},
onmouseenter: (e) => {
const currentPreference = canvas.canvasLayers.clipboardPreference;
let tooltipContent = '';
if (currentPreference === 'system') {
tooltipContent = `
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>
`;
} else {
tooltipContent = `
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>
`;
}
showTooltip(e.target, tooltipContent);
},
onmouseleave: hideTooltip
})
]),
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
@@ -644,7 +742,7 @@ async function createCanvasWidget(node, widget, app) {
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height; const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
canvas.updateOutputAreaSize(width, height); canvas.updateOutputAreaSize(width, height);
document.body.removeChild(dialog); document.body.removeChild(dialog);
// updateOutput is triggered by saveState in updateOutputAreaSize
}; };
document.getElementById('cancel-size').onclick = () => { document.getElementById('cancel-size').onclick = () => {
@@ -660,12 +758,17 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Layer Up", textContent: "Layer Up",
title: "Move selected layer(s) up", title: "Move selected layer(s) up",
onclick: () => canvas.moveLayerUp() onclick: () => canvas.canvasLayers.moveLayerUp()
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Layer Down", textContent: "Layer Down",
title: "Move selected layer(s) down", title: "Move selected layer(s) down",
onclick: () => canvas.moveLayerDown() onclick: () => canvas.canvasLayers.moveLayerDown()
}),
$el("button.painter-button.requires-selection", {
textContent: "Fuse",
title: "Flatten and merge selected layers into a single layer",
onclick: () => canvas.canvasLayers.fuseLayers()
}), }),
]), ]),
@@ -674,27 +777,27 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Rotate +90°", textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees", title: "Rotate selected layer(s) by +90 degrees",
onclick: () => canvas.rotateLayer(90) onclick: () => canvas.canvasLayers.rotateLayer(90)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Scale +5%", textContent: "Scale +5%",
title: "Increase size of selected layer(s) by 5%", title: "Increase size of selected layer(s) by 5%",
onclick: () => canvas.resizeLayer(1.05) onclick: () => canvas.canvasLayers.resizeLayer(1.05)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Scale -5%", textContent: "Scale -5%",
title: "Decrease size of selected layer(s) by 5%", title: "Decrease size of selected layer(s) by 5%",
onclick: () => canvas.resizeLayer(0.95) onclick: () => canvas.canvasLayers.resizeLayer(0.95)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Mirror H", textContent: "Mirror H",
title: "Mirror selected layer(s) horizontally", title: "Mirror selected layer(s) horizontally",
onclick: () => canvas.mirrorHorizontal() onclick: () => canvas.canvasLayers.mirrorHorizontal()
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Mirror V", textContent: "Mirror V",
title: "Mirror selected layer(s) vertically", title: "Mirror selected layer(s) vertically",
onclick: () => canvas.mirrorVertical() onclick: () => canvas.canvasLayers.mirrorVertical()
}), }),
]), ]),
@@ -716,16 +819,22 @@ async function createCanvasWidget(node, widget, app) {
const selectedLayer = canvas.selectedLayers[0]; const selectedLayer = canvas.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.getLayerImageData(selectedLayer); const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
const response = await fetch("/matting", { const response = await fetch("/matting", {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({image: imageData}) body: JSON.stringify({image: imageData})
}); });
if (!response.ok) throw new Error(`Server error: ${response.status} - ${response.statusText}`);
const result = await response.json(); const result = await response.json();
if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) {
errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`;
}
throw new Error(errorMsg);
}
const mattedImage = new Image(); const mattedImage = new Image();
mattedImage.src = result.matted_image; mattedImage.src = result.matted_image;
await mattedImage.decode(); await mattedImage.decode();
@@ -737,7 +846,7 @@ async function createCanvasWidget(node, widget, app) {
canvas.saveState(); canvas.saveState();
} catch (error) { } catch (error) {
log.error("Matting error:", error); log.error("Matting error:", error);
alert(`Error during matting process: ${error.message}`); alert(`Matting process failed:\n\n${error.message}`);
} finally { } finally {
button.classList.remove('loading'); button.classList.remove('loading');
button.removeChild(spinner); button.removeChild(spinner);
@@ -761,6 +870,13 @@ async function createCanvasWidget(node, widget, app) {
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
$el("div.painter-button-group", {id: "mask-controls"}, [ $el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button", {
textContent: "Edit Mask",
title: "Open the current canvas view in the mask editor",
onclick: () => {
canvas.startMaskEditor();
}
}),
$el("button.painter-button", { $el("button.painter-button", {
id: "mask-mode-btn", id: "mask-mode-btn",
textContent: "Draw Mask", textContent: "Draw Mask",
@@ -838,15 +954,15 @@ async function createCanvasWidget(node, widget, app) {
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
onclick: async () => { onclick: async () => {
try { try {
const stats = canvas.getGarbageCollectionStats(); const stats = canvas.imageReferenceManager.getStats();
log.info("GC Stats before cleanup:", stats); log.info("GC Stats before cleanup:", stats);
await canvas.runGarbageCollection(); await canvas.imageReferenceManager.manualGarbageCollection();
const newStats = canvas.getGarbageCollectionStats(); const newStats = canvas.imageReferenceManager.getStats();
log.info("GC Stats after cleanup:", newStats); log.info("GC Stats after cleanup:", newStats);
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`); alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
} catch (e) { } catch (e) {
log.error("Failed to run garbage collection:", e); log.error("Failed to run garbage collection:", e);
alert("Error running garbage collection. Check the console for details."); alert("Error running garbage collection. Check the console for details.");
@@ -879,7 +995,12 @@ async function createCanvasWidget(node, widget, app) {
const selectionCount = canvas.selectedLayers.length; const selectionCount = canvas.selectedLayers.length;
const hasSelection = selectionCount > 0; const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach(btn => { controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
btn.disabled = !hasSelection; // Special handling for Fuse button - requires at least 2 layers
if (btn.textContent === 'Fuse') {
btn.disabled = selectionCount < 2;
} else {
btn.disabled = !hasSelection;
}
}); });
const mattingBtn = controlPanel.querySelector('.matting-button'); const mattingBtn = controlPanel.querySelector('.matting-button');
if (mattingBtn && !mattingBtn.classList.contains('loading')) { if (mattingBtn && !mattingBtn.classList.contains('loading')) {
@@ -901,32 +1022,62 @@ async function createCanvasWidget(node, widget, app) {
canvas.updateHistoryButtons(); canvas.updateHistoryButtons();
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
canvasContainer.style.top = (controlsHeight + 10) + "px";
});
resizeObserver.observe(controlPanel.querySelector('.controls'));
const triggerWidget = node.widgets.find(w => w.name === "trigger"); const triggerWidget = node.widgets.find(w => w.name === "trigger");
const updateOutput = () => { const updateOutput = async () => {
triggerWidget.value = (triggerWidget.value + 1) % 99999999; triggerWidget.value = (triggerWidget.value + 1) % 99999999;
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
try {
const new_preview = new Image();
const blob = await canvas.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
node.imgs = [new_preview];
} else {
node.imgs = [];
}
} catch (error) {
console.error("Error updating node preview:", error);
}
}; };
// Tworzenie panelu warstw
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
const canvasContainer = $el("div.painterCanvasContainer.painter-container", { const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
style: { style: {
position: "absolute", position: "absolute",
top: "60px", top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
left: "10px", left: "10px",
right: "10px", right: "270px",
bottom: "10px", bottom: "10px",
overflow: "hidden" overflow: "hidden"
} }
}, [canvas.canvas]); }, [canvas.canvas]);
// Kontener dla panelu warstw
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
style: {
position: "absolute",
top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
right: "10px",
width: "250px",
bottom: "10px",
overflow: "hidden"
}
}, [layersPanel]);
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
const newTop = (controlsHeight + 10) + "px";
canvasContainer.style.top = newTop;
layersPanelContainer.style.top = newTop;
});
resizeObserver.observe(controlPanel.querySelector('.controls'));
canvas.canvas.addEventListener('focus', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -947,71 +1098,9 @@ async function createCanvasWidget(node, widget, app) {
width: "100%", width: "100%",
height: "100%" height: "100%"
} }
}, [controlPanel, canvasContainer]); }, [controlPanel, canvasContainer, layersPanelContainer]);
const handleFileLoad = async (file) => {
log.info("File dropped:", file.name);
if (!file.type.startsWith('image/')) {
log.info("Dropped file is not an image.");
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
log.debug("FileReader finished loading dropped file as data:URL.");
const img = new Image();
img.onload = async () => {
log.debug("Image object loaded from dropped data:URL.");
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height
);
const layer = {
image: img,
x: (canvas.width - img.width * scale) / 2,
y: (canvas.height - img.height * scale) / 2,
width: img.width * scale,
height: img.height * scale,
rotation: 0,
zIndex: canvas.layers.length,
blendMode: 'normal',
opacity: 1
};
canvas.layers.push(layer);
canvas.updateSelection([layer]);
canvas.render();
canvas.saveState();
log.info("Dropped layer added and state saved.");
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
mainContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.add('drag-over');
});
mainContainer.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
});
mainContainer.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
if (e.dataTransfer.files) {
for (const file of e.dataTransfer.files) {
await handleFileLoad(file);
}
}
});
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer); const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
@@ -1072,14 +1161,38 @@ async function createCanvasWidget(node, widget, app) {
if (!window.canvasExecutionStates) { if (!window.canvasExecutionStates) {
window.canvasExecutionStates = new Map(); window.canvasExecutionStates = new Map();
} }
node.canvasWidget = canvas; node.canvasWidget = canvas;
setTimeout(() => { setTimeout(() => {
canvas.loadInitialState(); canvas.loadInitialState();
// Renderuj panel warstw po załadowaniu stanu
if (canvas.canvasLayersPanel) {
canvas.canvasLayersPanel.renderLayers();
}
}, 100); }, 100);
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function (value) {
if (originalCallback) {
originalCallback.call(this, value);
}
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(value);
}
if (node.graph && node.graph.canvas) {
node.setDirtyCanvas(true, true);
}
};
}
return { return {
canvas: canvas, canvas: canvas,
panel: controlPanel panel: controlPanel
@@ -1154,7 +1267,6 @@ app.registerExtension({
return; return;
} }
// Iterate through every widget attached to this node
this.widgets.forEach(w => { this.widgets.forEach(w => {
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`); log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
}); });
@@ -1206,7 +1318,32 @@ app.registerExtension({
originalGetExtraMenuOptions?.apply(this, arguments); originalGetExtraMenuOptions?.apply(this, arguments);
const self = this; const self = this;
const maskEditorIndex = options.findIndex(option =>
option && option.content === "Open in MaskEditor"
);
if (maskEditorIndex !== -1) {
options.splice(maskEditorIndex, 1);
}
const newOptions = [ const newOptions = [
{
content: "Open in MaskEditor",
callback: async () => {
try {
log.info("Opening LayerForge canvas in MaskEditor");
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
await self.canvasWidget.startMaskEditor();
} else {
log.error("Canvas widget not available");
alert("Canvas not ready. Please try again.");
}
} catch (e) {
log.error("Error opening MaskEditor:", e);
alert(`Failed to open MaskEditor: ${e.message}`);
}
},
},
{ {
content: "Open Image", content: "Open Image",
callback: async () => { callback: async () => {
@@ -1220,6 +1357,19 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Open Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error opening image with mask:", e);
}
},
},
{ {
content: "Copy Image", content: "Copy Image",
callback: async () => { callback: async () => {
@@ -1234,6 +1384,20 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Copy Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Image with mask alpha copied to clipboard.");
} catch (e) {
log.error("Error copying image with mask:", e);
alert("Failed to copy image with mask to clipboard.");
}
},
},
{ {
content: "Save Image", content: "Save Image",
callback: async () => { callback: async () => {
@@ -1252,6 +1416,24 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Save Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'canvas_output_with_mask.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error saving image with mask:", e);
}
},
},
]; ];
if (options.length > 0) { if (options.length > 0) {
options.unshift({content: "___", disabled: true}); options.unshift({content: "___", disabled: true});

View File

@@ -8,7 +8,7 @@ export class MaskTool {
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas'); this.maskCanvas = document.createElement('canvas');
this.maskCtx = this.maskCanvas.getContext('2d'); this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
@@ -21,7 +21,7 @@ export class MaskTool {
this.lastPosition = null; this.lastPosition = null;
this.previewCanvas = document.createElement('canvas'); this.previewCanvas = document.createElement('canvas');
this.previewCtx = this.previewCanvas.getContext('2d'); this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
this.previewVisible = false; this.previewVisible = false;
this.previewCanvasInitialized = false; this.previewCanvasInitialized = false;
@@ -162,7 +162,7 @@ export class MaskTool {
if (this.brushHardness === 1) { if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else { } else {
// hardness: 1 = hard edge, 0 = soft edge
const innerRadius = gradientRadius * this.brushHardness; const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient( const gradient = this.maskCtx.createRadialGradient(
canvasX, canvasY, innerRadius, canvasX, canvasY, innerRadius,
@@ -220,7 +220,7 @@ export class MaskTool {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width; tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height; tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(this.maskCanvas, 0, 0); tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data; const data = imageData.data;
@@ -258,7 +258,7 @@ export class MaskTool {
this.maskCanvas.width = newWidth; this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight; this.maskCanvas.height = newHeight;
this.maskCtx = this.maskCanvas.getContext('2d'); this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (oldMask.width > 0 && oldMask.height > 0) { if (oldMask.width > 0 && oldMask.height > 0) {
@@ -279,4 +279,23 @@ export class MaskTool {
this.y += dy; this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`); log.info(`Mask position updated to (${this.x}, ${this.y})`);
} }
setMask(image) {
const destX = -this.x;
const destY = -this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
}
} }

93
js/state-saver.worker.js Normal file
View File

@@ -0,0 +1,93 @@
console.log('[StateWorker] Worker script loaded and running.');
const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState';
const DB_VERSION = 3;
let db;
function log(...args) {
console.log('[StateWorker]', ...args);
}
function error(...args) {
console.error('[StateWorker]', ...args);
}
function createDBRequest(store, operation, data, errorMessage) {
return new Promise((resolve, reject) => {
let request;
switch (operation) {
case 'put':
request = store.put(data);
break;
default:
reject(new Error(`Unknown operation: ${operation}`));
return;
}
request.onerror = (event) => {
error(errorMessage, event.target.error);
reject(errorMessage);
};
request.onsuccess = (event) => {
resolve(event.target.result);
};
});
}
function openDB() {
return new Promise((resolve, reject) => {
if (db) {
resolve(db);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => {
error("IndexedDB error:", event.target.error);
reject("Error opening IndexedDB.");
};
request.onsuccess = (event) => {
db = event.target.result;
log("IndexedDB opened successfully in worker.");
resolve(db);
};
request.onupgradeneeded = (event) => {
log("Upgrading IndexedDB in worker...");
const tempDb = event.target.result;
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
}
};
});
}
async function setCanvasState(id, state) {
const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
}
self.onmessage = async function(e) {
log('Message received from main thread:', e.data ? 'data received' : 'no data');
const { state, nodeId } = e.data;
if (!state || !nodeId) {
error('Invalid data received from main thread');
return;
}
try {
log(`Saving state for node: ${nodeId}`);
await setCanvasState(nodeId, state);
log(`State saved successfully for node: ${nodeId}`);
} catch (err) {
error(`Failed to save state for node: ${nodeId}`, err);
}
};

View File

@@ -0,0 +1,510 @@
import {createModuleLogger} from "./LoggerUtils.js";
import {api} from "../../../scripts/api.js";
import {ComfyApp} from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager');
export class ClipboardManager {
constructor(canvas) {
this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace'
}
/**
* Main paste handler that delegates to appropriate methods
* @param {string} addMode - The mode for adding the layer
* @param {string} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async handlePaste(addMode = 'mouse', preference = 'system') {
try {
log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers();
return true;
}
if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode);
if (success) {
return true;
}
log.info("No image found in ComfyUI Clipspace");
}
log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode);
} catch (err) {
log.error("ClipboardManager paste operation failed:", err);
return false;
}
}
/**
* Attempts to paste from ComfyUI Clipspace
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async tryClipspacePaste(addMode) {
try {
log.info("Attempting to paste from ComfyUI Clipspace");
const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node);
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) {
log.info("Successfully got image from ComfyUI Clipspace");
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = clipspaceImage.src;
return true;
}
}
return false;
} catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false;
}
}
/**
* System clipboard paste - handles both image data and text paths
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async trySystemClipboardPaste(addMode) {
log.info("ClipboardManager: Checking system clipboard for images and paths");
if (navigator.clipboard?.read) {
try {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
log.debug("Clipboard item types:", item.types);
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
try {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = event.target.result;
};
reader.readAsDataURL(blob);
log.info("Found image data in system clipboard");
return true;
} catch (error) {
log.debug("Error reading image data:", error);
}
}
const textTypes = ['text/plain', 'text/uri-list'];
for (const textType of textTypes) {
if (item.types.includes(textType)) {
try {
const textBlob = await item.getType(textType);
const text = await textBlob.text();
if (this.isValidImagePath(text)) {
log.info("Found image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
}
} catch (error) {
log.debug(`Error reading ${textType}:`, error);
}
}
}
}
} catch (error) {
log.debug("Modern clipboard API failed:", error);
}
}
if (navigator.clipboard?.readText) {
try {
const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text);
if (text && 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);
}
}
log.debug("No images or valid image paths found in system clipboard");
return false;
}
/**
* Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate
* @returns {boolean} - True if the text appears to be a valid image file path or URL
*/
isValidImagePath(text) {
if (!text || typeof text !== 'string') {
return false;
}
text = text.trim();
if (!text) {
return false;
}
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
try {
new URL(text);
log.debug("Detected valid URL:", text);
return true;
} catch (e) {
log.debug("Invalid URL format:", text);
return false;
}
}
const imageExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
'.svg', '.tiff', '.tif', '.ico', '.avif'
];
const hasImageExtension = imageExtensions.some(ext =>
text.toLowerCase().endsWith(ext)
);
if (!hasImageExtension) {
log.debug("No valid image extension found in:", text);
return false;
}
const pathPatterns = [
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
/^[\\\/]/, // Unix absolute path (/...)
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
/^[^\\\/]*[\\\/]/ // Contains path separators
];
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
if (isValidPath) {
log.debug("Detected valid local file path:", text);
} else {
log.debug("Invalid local file path format:", text);
}
return isValidPath;
}
/**
* Attempts to load an image from a file path using simplified methods
* @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadImageFromPath(filePath, addMode) {
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from URL:", filePath);
resolve(false);
};
img.src = filePath;
});
} catch (error) {
log.warn("Error loading image from URL:", error);
return false;
}
}
try {
log.info("Attempting to load local file via backend");
const success = await this.loadFileViaBackend(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("Backend loading failed:", error);
}
try {
log.info("Falling back to file picker");
const success = await this.promptUserForFile(filePath, addMode);
if (success) {
return true;
}
} catch (error) {
log.warn("File picker failed:", error);
}
this.showFilePathMessage(filePath);
return false;
}
/**
* Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadFileViaBackend(filePath, addMode) {
try {
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error);
return false;
}
const data = await response.json();
if (!data.success) {
log.debug("Backend returned error:", data.error);
return false;
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
} catch (error) {
log.debug("Error loading file via ComfyUI backend:", error);
return false;
}
}
/**
* Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard
* @param {string} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async promptUserForFile(originalPath, addMode) {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
const fileName = originalPath.split(/[\\\/]/).pop();
fileInput.onchange = async (event) => {
const file = event.target.files[0];
if (file && file.type.startsWith('image/')) {
try {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load selected image");
resolve(false);
};
img.src = e.target.result;
};
reader.onerror = () => {
log.warn("Failed to read selected file");
resolve(false);
};
reader.readAsDataURL(file);
} catch (error) {
log.warn("Error processing selected file:", error);
resolve(false);
}
} else {
log.warn("Selected file is not an image");
resolve(false);
}
document.body.removeChild(fileInput);
};
fileInput.oncancel = () => {
log.info("File selection cancelled by user");
document.body.removeChild(fileInput);
resolve(false);
};
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput);
fileInput.click();
});
}
/**
* Shows a message to the user about file path limitations
* @param {string} filePath - The file path that couldn't be loaded
*/
showFilePathMessage(filePath) {
const fileName = filePath.split(/[\\\/]/).pop();
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
this.showNotification(message, 5000);
log.info("Showed file path limitation message to user");
}
/**
* Shows a helpful message when clipboard appears empty and offers file picker
* @param {string} addMode - The mode for adding the layer
*/
showEmptyClipboardMessage(addMode) {
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #2d5aa0;
color: white;
padding: 14px 18px;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 10001;
max-width: 320px;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
border: 2px solid #4a7bc8;
transition: all 0.2s ease;
font-weight: 500;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">📁</span>
<span>${message}</span>
</div>
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
💡 Tip: You can also drag & drop files directly onto the canvas
</div>
`;
notification.onmouseenter = () => {
notification.style.backgroundColor = '#3d6bb0';
notification.style.borderColor = '#5a8bd8';
notification.style.transform = 'translateY(-1px)';
};
notification.onmouseleave = () => {
notification.style.backgroundColor = '#2d5aa0';
notification.style.borderColor = '#4a7bc8';
notification.style.transform = 'translateY(0)';
};
notification.onclick = async () => {
document.body.removeChild(notification);
try {
const success = await this.promptUserForFile('image_file.jpg', addMode);
if (success) {
log.info("Successfully loaded image via empty clipboard file picker");
}
} catch (error) {
log.warn("Error with empty clipboard file picker:", error);
}
};
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 12000);
log.info("Showed enhanced empty clipboard message with file picker option");
}
/**
* Shows a temporary notification to the user
* @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds
*/
showNotification(message, duration = 3000) {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
font-size: 14px;
line-height: 1.4;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
}

View File

@@ -8,7 +8,7 @@
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/ */
export function generateUUID() { export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16); return v.toString(16);
}); });
@@ -42,7 +42,7 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
top: layer.y, top: layer.y,
bottom: layer.y + layer.height bottom: layer.y + layer.height
}; };
const x_adjustments = [ const x_adjustments = [
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left}, {type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right} {type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
@@ -52,17 +52,17 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top}, {type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom} {type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
]; ];
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta)); x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta)); y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
const bestXSnap = x_adjustments const bestXSnap = x_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
const bestYSnap = y_adjustments const bestYSnap = y_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
return { return {
dx: bestXSnap ? bestXSnap.delta : 0, dx: bestXSnap ? bestXSnap.delta : 0,
dy: bestYSnap ? bestYSnap.delta : 0 dy: bestYSnap ? bestYSnap.delta : 0
@@ -145,7 +145,7 @@ export function getStateSignature(layers) {
if (layer.image && layer.image.src) { if (layer.image && layer.image.src) {
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
} }
return sig; return sig;
})); }));
} }
@@ -179,7 +179,7 @@ export function debounce(func, wait, immediate) {
*/ */
export function throttle(func, limit) { export function throttle(func, limit) {
let inThrottle; let inThrottle;
return function(...args) { return function (...args) {
if (!inThrottle) { if (!inThrottle) {
func.apply(this, args); func.apply(this, args);
inThrottle = true; inThrottle = true;
@@ -241,7 +241,7 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
if (width) canvas.width = width; if (width) canvas.width = width;
if (height) canvas.height = height; if (height) canvas.height = height;
const ctx = canvas.getContext(contextType, contextOptions); const ctx = canvas.getContext(contextType, contextOptions);
return { canvas, ctx }; return {canvas, ctx};
} }
/** /**
@@ -284,5 +284,5 @@ export function generateUniqueFileName(baseName, nodeId) {
*/ */
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) { export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
return pointX >= rectX && pointX <= rectX + rectWidth && return pointX >= rectX && pointX <= rectX + rectWidth &&
pointY >= rectY && pointY <= rectY + rectHeight; pointY >= rectY && pointY <= rectY + rectHeight;
} }

View File

@@ -1,5 +1,6 @@
import {createModuleLogger} from "./LoggerUtils.js"; import {createModuleLogger} from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
export function validateImageData(data) { export function validateImageData(data) {
@@ -114,7 +115,7 @@ export function applyMaskToImageData(imageData, maskData) {
}; };
} }
export const prepareImageForCanvas = withErrorHandling(function(inputImage) { export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
log.info("Preparing image for canvas:", inputImage); log.info("Preparing image for canvas:", inputImage);
if (Array.isArray(inputImage)) { if (Array.isArray(inputImage)) {
@@ -122,7 +123,7 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
} }
if (!inputImage || !inputImage.shape || !inputImage.data) { if (!inputImage || !inputImage.shape || !inputImage.data) {
throw createValidationError("Invalid input image format", { inputImage }); throw createValidationError("Invalid input image format", {inputImage});
} }
const shape = inputImage.shape; const shape = inputImage.shape;
@@ -161,29 +162,29 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji * @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
* @returns {Promise<Object>} Tensor z danymi obrazu * @returns {Promise<Object>} Tensor z danymi obrazu
*/ */
export const imageToTensor = withErrorHandling(async function(image) { export const imageToTensor = withErrorHandling(async function (image) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth; canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight; canvas.height = image.height || image.naturalHeight;
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = new Float32Array(canvas.width * canvas.height * 3); const data = new Float32Array(canvas.width * canvas.height * 3);
for (let i = 0; i < imageData.data.length; i += 4) { for (let i = 0; i < imageData.data.length; i += 4) {
const pixelIndex = i / 4; const pixelIndex = i / 4;
data[pixelIndex * 3] = imageData.data[i] / 255; data[pixelIndex * 3] = imageData.data[i] / 255;
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
} }
return { return {
data: data, data: data,
shape: [1, canvas.height, canvas.width, 3], shape: [1, canvas.height, canvas.width, 3],
@@ -197,33 +198,33 @@ export const imageToTensor = withErrorHandling(async function(image) {
* @param {Object} tensor - Tensor z danymi obrazu * @param {Object} tensor - Tensor z danymi obrazu
* @returns {Promise<HTMLImageElement>} Obraz HTML * @returns {Promise<HTMLImageElement>} Obraz HTML
*/ */
export const tensorToImage = withErrorHandling(async function(tensor) { export const tensorToImage = withErrorHandling(async function (tensor) {
if (!tensor || !tensor.data || !tensor.shape) { if (!tensor || !tensor.data || !tensor.shape) {
throw createValidationError("Invalid tensor format", { tensor }); throw createValidationError("Invalid tensor format", {tensor});
} }
const [, height, width, channels] = tensor.shape; const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
const data = tensor.data; const data = tensor.data;
for (let i = 0; i < width * height; i++) { for (let i = 0; i < width * height; i++) {
const pixelIndex = i * 4; const pixelIndex = i * 4;
const tensorIndex = i * channels; const tensorIndex = i * channels;
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255); imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255); imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
imageData.data[pixelIndex + 3] = 255; imageData.data[pixelIndex + 3] = 255;
} }
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
@@ -239,27 +240,27 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
* @param {number} maxHeight - Maksymalna wysokość * @param {number} maxHeight - Maksymalna wysokość
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz * @returns {Promise<HTMLImageElement>} Przeskalowany obraz
*/ */
export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) { export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width || image.naturalWidth; const originalWidth = image.width || image.naturalWidth;
const originalHeight = image.height || image.naturalHeight; const originalHeight = image.height || image.naturalHeight;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale); const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale); const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth; canvas.width = newWidth;
canvas.height = newHeight; canvas.height = newHeight;
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newWidth, newHeight); ctx.drawImage(image, 0, 0, newWidth, newHeight);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
@@ -274,7 +275,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
* @param {number} size - Rozmiar miniatury (kwadrat) * @param {number} size - Rozmiar miniatury (kwadrat)
* @returns {Promise<HTMLImageElement>} Miniatura * @returns {Promise<HTMLImageElement>} Miniatura
*/ */
export const createThumbnail = withErrorHandling(async function(image, size = 128) { export const createThumbnail = withErrorHandling(async function (image, size = 128) {
return resizeImage(image, size, size); return resizeImage(image, size, size);
}, 'createThumbnail'); }, 'createThumbnail');
@@ -285,19 +286,19 @@ export const createThumbnail = withErrorHandling(async function(image, size = 12
* @param {number} quality - Jakość (0-1) dla formatów stratnych * @param {number} quality - Jakość (0-1) dla formatów stratnych
* @returns {string} Base64 string * @returns {string} Base64 string
*/ */
export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) { export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth; canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight; canvas.height = image.height || image.naturalHeight;
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
const mimeType = `image/${format}`; const mimeType = `image/${format}`;
return canvas.toDataURL(mimeType, quality); return canvas.toDataURL(mimeType, quality);
}, 'imageToBase64'); }, 'imageToBase64');
@@ -307,7 +308,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
* @param {string} base64 - Base64 string * @param {string} base64 - Base64 string
* @returns {Promise<HTMLImageElement>} Obraz * @returns {Promise<HTMLImageElement>} Obraz
*/ */
export const base64ToImage = withErrorHandling(function(base64) { export const base64ToImage = withErrorHandling(function (base64) {
if (!base64) { if (!base64) {
throw createValidationError("Base64 string is required"); throw createValidationError("Base64 string is required");
} }
@@ -326,10 +327,10 @@ export const base64ToImage = withErrorHandling(function(base64) {
* @returns {boolean} Czy obraz jest prawidłowy * @returns {boolean} Czy obraz jest prawidłowy
*/ */
export function isValidImage(image) { export function isValidImage(image) {
return image && return image &&
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
image.width > 0 && image.width > 0 &&
image.height > 0; image.height > 0;
} }
/** /**
@@ -371,18 +372,18 @@ export function createImageFromSource(source) {
* @param {string} color - Kolor tła (CSS color) * @param {string} color - Kolor tła (CSS color)
* @returns {Promise<HTMLImageElement>} Pusty obraz * @returns {Promise<HTMLImageElement>} Pusty obraz
*/ */
export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') { export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
if (color !== 'transparent') { if (color !== 'transparent') {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);

View File

@@ -11,9 +11,9 @@ import {logger, LogLevel} from "../logger.js";
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) * @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
* @returns {Object} Obiekt z metodami logowania * @returns {Object} Obiekt z metodami logowania
*/ */
export function createModuleLogger(moduleName, level = LogLevel.NONE) { export function createModuleLogger(moduleName, level = LogLevel.DEBUG) {
logger.setModuleLevel(moduleName, level); logger.setModuleLevel(moduleName, level);
return { return {
debug: (...args) => logger.debug(moduleName, ...args), debug: (...args) => logger.debug(moduleName, ...args),
info: (...args) => logger.info(moduleName, ...args), info: (...args) => logger.info(moduleName, ...args),
@@ -31,7 +31,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
const stack = new Error().stack; const stack = new Error().stack;
const match = stack.match(/\/([^\/]+)\.js/); const match = stack.match(/\/([^\/]+)\.js/);
const moduleName = match ? match[1] : 'Unknown'; const moduleName = match ? match[1] : 'Unknown';
return createModuleLogger(moduleName, level); return createModuleLogger(moduleName, level);
} }
@@ -43,7 +43,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
* @returns {Function} Opakowana funkcja * @returns {Function} Opakowana funkcja
*/ */
export function withErrorLogging(operation, log, operationName) { export function withErrorLogging(operation, log, operationName) {
return async function(...args) { return async function (...args) {
try { try {
log.debug(`Starting ${operationName}`); log.debug(`Starting ${operationName}`);
const result = await operation.apply(this, args); const result = await operation.apply(this, args);
@@ -62,10 +62,10 @@ export function withErrorLogging(operation, log, operationName) {
* @param {string} methodName - Nazwa metody * @param {string} methodName - Nazwa metody
*/ */
export function logMethod(log, methodName) { export function logMethod(log, methodName) {
return function(target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function(...args) { descriptor.value = async function (...args) {
try { try {
log.debug(`${methodName || propertyKey} started`); log.debug(`${methodName || propertyKey} started`);
const result = await originalMethod.apply(this, args); const result = await originalMethod.apply(this, args);
@@ -76,7 +76,7 @@ export function logMethod(log, methodName) {
throw error; throw error;
} }
}; };
return descriptor; return descriptor;
}; };
} }

View File

@@ -45,7 +45,7 @@ class WebSocketManager {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
log.debug("Received message:", data); log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) { if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId); const callback = this.ackCallbacks.get(data.nodeId);
if (callback) { if (callback) {
@@ -130,7 +130,6 @@ class WebSocketManager {
log.warn("WebSocket not open. Queuing message."); log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message); this.messageQueue.push(message);
if (!this.isConnecting) { if (!this.isConnecting) {
this.connect(); this.connect();
@@ -147,7 +146,6 @@ class WebSocketManager {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`); log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift(); const message = this.messageQueue.shift();
this.socket.send(message); this.socket.send(message);

174
js/utils/mask_utils.js Normal file
View File

@@ -0,0 +1,174 @@
import {createModuleLogger} from "./LoggerUtils.js";
const log = createModuleLogger('MaskUtils');
export function new_editor(app) {
if (!app) return false;
return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor')
}
function get_mask_editor_element(app) {
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement
}
export function mask_editor_showing(app) {
const editor = get_mask_editor_element(app);
return editor && editor.style.display !== "none";
}
export function hide_mask_editor() {
if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none'
}
function get_mask_editor_cancel_button(app) {
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
if (cancelButton) {
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
return cancelButton;
}
const cancelSelectors = [
'button[onclick*="cancel"]',
'button[onclick*="Cancel"]',
'input[value="Cancel"]'
];
for (const selector of cancelSelectors) {
try {
const button = document.querySelector(selector);
if (button) {
log.debug("Found cancel button with selector:", selector);
return button;
}
} catch (e) {
log.warn("Invalid selector:", selector, e);
}
}
const allButtons = document.querySelectorAll('button, input[type="button"]');
for (const button of allButtons) {
const text = button.textContent || button.value || '';
if (text.toLowerCase().includes('cancel')) {
log.debug("Found cancel button by text content:", text);
return button;
}
}
const editorElement = get_mask_editor_element(app);
if (editorElement) {
return editorElement?.parentElement?.lastChild?.childNodes[2];
}
return null;
}
function get_mask_editor_save_button(app) {
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton")
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2]
}
export function mask_editor_listen_for_cancel(app, callback) {
let attempts = 0;
const maxAttempts = 50; // 5 sekund
const findAndAttachListener = () => {
attempts++;
const cancel_button = get_mask_editor_cancel_button(app);
if (cancel_button && !cancel_button.filter_listener_added) {
log.info("Cancel button found, attaching listener");
cancel_button.addEventListener('click', callback);
cancel_button.filter_listener_added = true;
return true; // Znaleziono i podłączono
} else if (attempts < maxAttempts) {
setTimeout(findAndAttachListener, 100);
} else {
log.warn("Could not find cancel button after", maxAttempts, "attempts");
const globalClickHandler = (event) => {
const target = event.target;
const text = target.textContent || target.value || '';
if (text.toLowerCase().includes('cancel') ||
target.id.toLowerCase().includes('cancel') ||
target.className.toLowerCase().includes('cancel')) {
log.info("Cancel detected via global click handler");
callback();
document.removeEventListener('click', globalClickHandler);
}
};
document.addEventListener('click', globalClickHandler);
log.debug("Added global click handler for cancel detection");
}
};
findAndAttachListener();
}
export function press_maskeditor_save(app) {
get_mask_editor_save_button(app)?.click()
}
export function press_maskeditor_cancel(app) {
get_mask_editor_cancel_button(app)?.click()
}
/**
* Uruchamia mask editor z predefiniowaną maską
* @param {Object} canvasInstance - Instancja Canvas
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) {
if (!canvasInstance || !maskImage) {
log.error('Canvas instance and mask image are required');
return;
}
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
}
/**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Object} canvasInstance - Instancja Canvas
*/
export function start_mask_editor_auto(canvasInstance) {
if (!canvasInstance) {
log.error('Canvas instance is required');
return;
}
canvasInstance.startMaskEditor();
}
/**
* Tworzy maskę z obrazu dla użycia w mask editorze
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
* @returns {Promise<Image>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = imageSrc;
});
}
/**
* Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<Image>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "layerforge" name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing." description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.2.4" version = "1.3.3"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]