mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b901a8d6 | ||
|
|
826f448af9 | ||
|
|
42e13f1551 | ||
|
|
562b0db042 | ||
|
|
038dad759a | ||
|
|
6f4602eb31 | ||
|
|
cac7652b7d | ||
|
|
d5573f426c | ||
|
|
979fcd59bc | ||
|
|
4ec470a3ed | ||
|
|
8a456db6a0 | ||
|
|
55a60d710c | ||
|
|
e40c85b0ee | ||
|
|
145d64ea39 | ||
|
|
281350f75a | ||
|
|
dc3197e914 | ||
|
|
5a71eb46db | ||
|
|
35d3c77ba8 | ||
|
|
813df556fb | ||
|
|
6372aea90c | ||
|
|
9ab8680a85 | ||
|
|
90a0c6476f | ||
|
|
3544576605 | ||
|
|
3b16c00b66 | ||
|
|
d0ade5ebc7 | ||
|
|
9dcf38b36d | ||
|
|
7a7c8f2295 | ||
|
|
fc8ebedb1e | ||
|
|
98037324cd | ||
|
|
372a7a4718 | ||
|
|
8a18e4ec30 | ||
|
|
ade3cd7818 | ||
|
|
4a9dc3219b |
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
24
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
Normal 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
143
.github/workflows/ComfyUIdownloads.yml
vendored
Normal 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 "[]($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
90
.github/workflows/clone.yml
vendored
Normal 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 "[]($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 }}
|
||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Auto Release with Version Patch
|
||||
name: Auto Release with Version Check
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -19,22 +19,26 @@ jobs:
|
||||
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||
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
|
||||
run: |
|
||||
BASE="v${{ steps.version.outputs.base_version }}"
|
||||
TAG=$BASE
|
||||
COUNT=0
|
||||
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Fetch remote tags
|
||||
git fetch --tags
|
||||
|
||||
while git rev-parse "$TAG" >/dev/null 2>&1; do
|
||||
COUNT=$((COUNT + 1))
|
||||
TAG="$BASE.$COUNT"
|
||||
done
|
||||
|
||||
echo "final_tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Get latest commit message
|
||||
id: last_commit
|
||||
run: |
|
||||
msg=$(git log -1 --pretty=%B)
|
||||
msg=${msg//$'\n'/\\n}
|
||||
echo "commit_msg=$msg" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -43,6 +47,10 @@ jobs:
|
||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||
body: |
|
||||
📦 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:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
13
CLONE.md
Normal file
13
CLONE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
**Markdown**
|
||||
|
||||
```markdown
|
||||
[](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
96
Doc/ComfyApi
Normal 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
72
Doc/ComfyApp
Normal 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
75
Doc/LitegraphService
Normal 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
76
Doc/MaskEditor
Normal 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
13
LAYERFORGE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
**Markdown**
|
||||
|
||||
```markdown
|
||||
[](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>
|
||||
```
|
||||
22
README.md
22
README.md
@@ -1,26 +1,24 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
|
||||
### Why LayerForge?
|
||||
|
||||
- **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**:
|
||||
>
|
||||
- [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/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -168,6 +168,7 @@ class CanvasNode:
|
||||
return {
|
||||
"required": {
|
||||
"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}),
|
||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
||||
},
|
||||
@@ -231,7 +232,7 @@ class CanvasNode:
|
||||
|
||||
_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:
|
||||
|
||||
@@ -470,6 +471,70 @@ class CanvasNode:
|
||||
'error': str(e)
|
||||
}, 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):
|
||||
|
||||
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
||||
|
||||
365
example_workflows/LayerForge_test_workflow.json
Normal file
365
example_workflows/LayerForge_test_workflow.json
Normal 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
|
||||
}
|
||||
BIN
example_workflows/LayerForge_test_workflow.png
Normal file
BIN
example_workflows/LayerForge_test_workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 335 KiB |
1120
js/Canvas.js
1120
js/Canvas.js
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ export class CanvasIO {
|
||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
await this.canvas.saveStateToDB(true);
|
||||
await this.canvas.canvasState.saveStateToDB(true);
|
||||
const nodeId = this.canvas.node.id;
|
||||
const delay = (nodeId % 10) * 50;
|
||||
if (delay > 0) {
|
||||
@@ -102,7 +102,7 @@ export class CanvasIO {
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
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);
|
||||
|
||||
@@ -279,7 +279,7 @@ export class CanvasIO {
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
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);
|
||||
|
||||
@@ -374,7 +374,7 @@ export class CanvasIO {
|
||||
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,
|
||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||
width: inputImage.width * scale,
|
||||
@@ -403,7 +403,7 @@ export class CanvasIO {
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = tensor.width;
|
||||
canvas.height = tensor.height;
|
||||
|
||||
@@ -611,7 +611,7 @@ export class CanvasIO {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
@@ -684,7 +684,7 @@ export class CanvasIO {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = img.width;
|
||||
tempCanvas.height = img.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
tempCtx.drawImage(img, 0, 0);
|
||||
|
||||
@@ -693,7 +693,7 @@ export class CanvasIO {
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = img.width;
|
||||
maskCanvas.height = img.height;
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
maskCtx.drawImage(mask, 0, 0);
|
||||
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
@@ -744,7 +744,7 @@ export class CanvasIO {
|
||||
img.src = result.image_data;
|
||||
});
|
||||
|
||||
await this.canvas.addLayerWithImage(img, {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.canvas.width,
|
||||
|
||||
@@ -34,6 +34,8 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.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.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
@@ -42,6 +44,13 @@ export class CanvasInteractions {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||
}
|
||||
|
||||
resetInteractionState() {
|
||||
@@ -86,26 +95,34 @@ export class CanvasInteractions {
|
||||
}
|
||||
this.interaction.lastClickTime = currentTime;
|
||||
|
||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (e.button === 2) {
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
|
||||
e.preventDefault(); // Prevent context menu
|
||||
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
this.startCanvasResize(worldCoords);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (transformTarget) {
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (clickedLayerResult) {
|
||||
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
|
||||
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
|
||||
return;
|
||||
}
|
||||
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
this.startCanvasResize(worldCoords);
|
||||
} else {
|
||||
this.startPanning(e);
|
||||
}
|
||||
|
||||
this.startPanning(e);
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
@@ -176,7 +193,7 @@ export class CanvasInteractions {
|
||||
|
||||
if (interactionEnded) {
|
||||
this.canvas.saveState();
|
||||
this.canvas.saveStateToDB(true);
|
||||
this.canvas.canvasState.saveStateToDB(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +211,11 @@ export class CanvasInteractions {
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.internalClipboard = [];
|
||||
log.info("Internal clipboard cleared - mouse left canvas");
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter(e) {
|
||||
@@ -202,6 +224,11 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
|
||||
handleContextMenu(e) {
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
@@ -297,16 +324,16 @@ export class CanvasInteractions {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
this.canvas.redo();
|
||||
this.canvas.canvasState.redo();
|
||||
} else {
|
||||
this.canvas.undo();
|
||||
this.canvas.canvasState.undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.redo();
|
||||
this.canvas.canvasState.redo();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -324,30 +351,27 @@ export class CanvasInteractions {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) {
|
||||
this.canvas.redo();
|
||||
this.canvas.canvasState.redo();
|
||||
} else {
|
||||
this.canvas.undo();
|
||||
this.canvas.canvasState.undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'y') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.redo();
|
||||
this.canvas.canvasState.redo();
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'c') {
|
||||
if (this.canvas.selectedLayers.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.copySelectedLayers();
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.handlePaste('mouse');
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -399,7 +423,7 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
updateCursor(worldCoords) {
|
||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
|
||||
if (transformTarget) {
|
||||
const handleName = transformTarget.handle;
|
||||
@@ -409,7 +433,7 @@ export class CanvasInteractions {
|
||||
'rot': 'grab'
|
||||
};
|
||||
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';
|
||||
} else {
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
@@ -432,7 +456,7 @@ export class CanvasInteractions {
|
||||
} else {
|
||||
this.interaction.mode = 'resizing';
|
||||
this.interaction.resizeHandle = handle;
|
||||
const handles = this.canvas.getHandles(layer);
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
const oppositeHandleKey = {
|
||||
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
||||
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
||||
@@ -712,4 +736,130 @@ export class CanvasInteractions {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import {saveImage, removeImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
|
||||
import {app, ComfyApp} from "../../scripts/app.js";
|
||||
import {ClipboardManager} from "./utils/ClipboardManager.js";
|
||||
|
||||
const log = createModuleLogger('CanvasLayers');
|
||||
|
||||
export class CanvasLayers {
|
||||
constructor(canvasLayers) {
|
||||
this.canvasLayers = canvasLayers;
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas);
|
||||
this.blendModes = [
|
||||
{name: 'normal', label: 'Normal'},
|
||||
{name: 'multiply', label: 'Multiply'},
|
||||
@@ -26,85 +29,120 @@ export class CanvasLayers {
|
||||
this.blendOpacity = 100;
|
||||
this.isAdjustingOpacity = false;
|
||||
this.internalClipboard = [];
|
||||
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||
}
|
||||
|
||||
async copySelectedLayers() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
||||
this.internalClipboard = this.canvasLayers.selectedLayers.map(layer => ({...layer}));
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
|
||||
this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer}));
|
||||
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
|
||||
try {
|
||||
const blob = await this.getFlattenedSelectionAsBlob();
|
||||
if (blob) {
|
||||
|
||||
const blob = await this.getFlattenedSelectionAsBlob();
|
||||
if (!blob) {
|
||||
log.warn("Failed to create flattened selection blob");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clipboardPreference === 'clipspace') {
|
||||
try {
|
||||
|
||||
const dataURL = await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
|
||||
if (this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [img];
|
||||
} else {
|
||||
this.canvas.node.imgs = [img];
|
||||
}
|
||||
|
||||
if (ComfyApp.copyToClipspace) {
|
||||
ComfyApp.copyToClipspace(this.canvas.node);
|
||||
log.info("Flattened selection copied to ComfyUI Clipspace.");
|
||||
} else {
|
||||
log.warn("ComfyUI copyToClipspace not available");
|
||||
}
|
||||
};
|
||||
img.src = dataURL;
|
||||
|
||||
} catch (error) {
|
||||
log.error("Failed to copy image to ComfyUI Clipspace:", error);
|
||||
|
||||
try {
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
log.info("Fallback: Flattened selection copied to system clipboard.");
|
||||
} catch (fallbackError) {
|
||||
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
try {
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
log.info("Flattened selection copied to the system clipboard.");
|
||||
log.info("Flattened selection copied to system clipboard.");
|
||||
} catch (error) {
|
||||
log.error("Failed to copy image to system clipboard:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to copy image to system clipboard:", error);
|
||||
}
|
||||
}
|
||||
|
||||
pasteLayers() {
|
||||
if (this.internalClipboard.length === 0) return;
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.saveState();
|
||||
const newLayers = [];
|
||||
const pasteOffset = 20;
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.internalClipboard.forEach(layer => {
|
||||
minX = Math.min(minX, layer.x);
|
||||
minY = Math.min(minY, layer.y);
|
||||
maxX = Math.max(maxX, layer.x + layer.width);
|
||||
maxY = Math.max(maxY, layer.y + layer.height);
|
||||
});
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
const mouseX = this.canvas.lastMousePosition.x;
|
||||
const mouseY = this.canvas.lastMousePosition.y;
|
||||
const offsetX = mouseX - centerX;
|
||||
const offsetY = mouseY - centerY;
|
||||
|
||||
this.internalClipboard.forEach(clipboardLayer => {
|
||||
const newLayer = {
|
||||
...clipboardLayer,
|
||||
x: clipboardLayer.x + pasteOffset / this.canvasLayers.viewport.zoom,
|
||||
y: clipboardLayer.y + pasteOffset / this.canvasLayers.viewport.zoom,
|
||||
zIndex: this.canvasLayers.layers.length
|
||||
x: clipboardLayer.x + offsetX,
|
||||
y: clipboardLayer.y + offsetY,
|
||||
zIndex: this.canvas.layers.length
|
||||
};
|
||||
this.canvasLayers.layers.push(newLayer);
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
});
|
||||
|
||||
this.canvasLayers.updateSelection(newLayers);
|
||||
this.canvasLayers.render();
|
||||
log.info(`Pasted ${newLayers.length} layer(s).`);
|
||||
this.canvas.updateSelection(newLayers);
|
||||
this.canvas.render();
|
||||
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
|
||||
}
|
||||
|
||||
async handlePaste(addMode = 'mouse') {
|
||||
try {
|
||||
if (!navigator.clipboard?.read) {
|
||||
log.info("Browser does not support clipboard read API. Falling back to internal paste.");
|
||||
this.pasteLayers();
|
||||
return;
|
||||
}
|
||||
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
|
||||
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
let imagePasted = false;
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
imagePasted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!imagePasted) {
|
||||
this.pasteLayers();
|
||||
}
|
||||
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
|
||||
|
||||
} catch (err) {
|
||||
log.error("Paste operation failed, falling back to internal paste. Error:", err);
|
||||
this.pasteLayers();
|
||||
log.error("Paste operation failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required for layer creation");
|
||||
@@ -113,24 +151,24 @@ export class CanvasLayers {
|
||||
log.debug("Adding layer with image:", image, "with mode:", addMode);
|
||||
const imageId = generateUUID();
|
||||
await saveImage(imageId, image.src);
|
||||
this.canvasLayers.imageCache.set(imageId, image.src);
|
||||
|
||||
this.canvas.imageCache.set(imageId, image.src);
|
||||
|
||||
let finalWidth = image.width;
|
||||
let finalHeight = image.height;
|
||||
let finalX, finalY;
|
||||
|
||||
if (addMode === 'fit') {
|
||||
const scale = Math.min(this.canvasLayers.width / image.width, this.canvasLayers.height / image.height);
|
||||
const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height);
|
||||
finalWidth = image.width * scale;
|
||||
finalHeight = image.height * scale;
|
||||
finalX = (this.canvasLayers.width - finalWidth) / 2;
|
||||
finalY = (this.canvasLayers.height - finalHeight) / 2;
|
||||
finalX = (this.canvas.width - finalWidth) / 2;
|
||||
finalY = (this.canvas.height - finalHeight) / 2;
|
||||
} else if (addMode === 'mouse') {
|
||||
finalX = this.canvasLayers.lastMousePosition.x - finalWidth / 2;
|
||||
finalY = this.canvasLayers.lastMousePosition.y - finalHeight / 2;
|
||||
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
|
||||
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
|
||||
} else { // 'center' or 'default'
|
||||
finalX = (this.canvasLayers.width - finalWidth) / 2;
|
||||
finalY = (this.canvasLayers.height - finalHeight) / 2;
|
||||
finalX = (this.canvas.width - finalWidth) / 2;
|
||||
finalY = (this.canvas.height - finalHeight) / 2;
|
||||
}
|
||||
|
||||
const layer = {
|
||||
@@ -143,16 +181,16 @@ export class CanvasLayers {
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvasLayers.layers.length,
|
||||
zIndex: this.canvas.layers.length,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
...layerProps
|
||||
};
|
||||
|
||||
this.canvasLayers.layers.push(layer);
|
||||
this.canvasLayers.updateSelection([layer]);
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return layer;
|
||||
@@ -162,53 +200,27 @@ export class CanvasLayers {
|
||||
return this.addLayerWithImage(image);
|
||||
}
|
||||
|
||||
async removeLayer(index) {
|
||||
if (index >= 0 && index < this.canvasLayers.layers.length) {
|
||||
const layer = this.canvasLayers.layers[index];
|
||||
if (layer.imageId) {
|
||||
const isImageUsedElsewhere = this.canvasLayers.layers.some((l, i) => i !== index && l.imageId === layer.imageId);
|
||||
if (!isImageUsedElsewhere) {
|
||||
await removeImage(layer.imageId);
|
||||
this.canvasLayers.imageCache.delete(layer.imageId);
|
||||
}
|
||||
}
|
||||
this.canvasLayers.layers.splice(index, 1);
|
||||
this.canvasLayers.selectedLayer = this.canvasLayers.layers[this.canvasLayers.layers.length - 1] || null;
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
moveLayer(fromIndex, toIndex) {
|
||||
if (fromIndex >= 0 && fromIndex < this.canvasLayers.layers.length &&
|
||||
toIndex >= 0 && toIndex < this.canvasLayers.layers.length) {
|
||||
const layer = this.canvasLayers.layers.splice(fromIndex, 1)[0];
|
||||
this.canvasLayers.layers.splice(toIndex, 0, layer);
|
||||
this.canvasLayers.render();
|
||||
}
|
||||
}
|
||||
|
||||
moveLayerUp() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
||||
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
|
||||
|
||||
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
|
||||
|
||||
sortedIndices.forEach(index => {
|
||||
const targetIndex = index + 1;
|
||||
|
||||
if (targetIndex < this.canvasLayers.layers.length && !selectedIndicesSet.has(targetIndex)) {
|
||||
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
|
||||
if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) {
|
||||
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
|
||||
}
|
||||
});
|
||||
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
moveLayerDown() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
||||
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
|
||||
|
||||
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
|
||||
|
||||
@@ -216,17 +228,46 @@ export class CanvasLayers {
|
||||
const targetIndex = index - 1;
|
||||
|
||||
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
|
||||
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
|
||||
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
|
||||
}
|
||||
});
|
||||
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar wybranych warstw
|
||||
* @param {number} scale - Skala zmiany rozmiaru
|
||||
*/
|
||||
resizeLayer(scale) {
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
|
||||
this.canvas.selectedLayers.forEach(layer => {
|
||||
layer.width *= scale;
|
||||
layer.height *= scale;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obraca wybrane warstwy
|
||||
* @param {number} angle - Kąt obrotu w stopniach
|
||||
*/
|
||||
rotateLayer(angle) {
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
|
||||
this.canvas.selectedLayers.forEach(layer => {
|
||||
layer.rotation += angle;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
getLayerAtPosition(worldX, worldY) {
|
||||
for (let i = this.canvasLayers.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvasLayers.layers[i];
|
||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.layers[i];
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
@@ -253,12 +294,12 @@ export class CanvasLayers {
|
||||
}
|
||||
|
||||
async mirrorHorizontal() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
|
||||
const promises = this.canvasLayers.selectedLayers.map(layer => {
|
||||
const promises = this.canvas.selectedLayers.map(layer => {
|
||||
return new Promise(resolve => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
tempCanvas.width = layer.image.width;
|
||||
tempCanvas.height = layer.image.height;
|
||||
|
||||
@@ -276,17 +317,17 @@ export class CanvasLayers {
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
async mirrorVertical() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
||||
if (this.canvas.selectedLayers.length === 0) return;
|
||||
|
||||
const promises = this.canvasLayers.selectedLayers.map(layer => {
|
||||
const promises = this.canvas.selectedLayers.map(layer => {
|
||||
return new Promise(resolve => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
tempCanvas.width = layer.image.width;
|
||||
tempCanvas.height = layer.image.height;
|
||||
|
||||
@@ -304,14 +345,14 @@ export class CanvasLayers {
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
this.canvasLayers.render();
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
async getLayerImageData(layer) {
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
tempCanvas.width = layer.width;
|
||||
tempCanvas.height = layer.height;
|
||||
@@ -342,52 +383,24 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||
if (saveHistory) {
|
||||
this.canvasLayers.saveState();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
this.canvasLayers.width = width;
|
||||
this.canvasLayers.height = height;
|
||||
this.canvasLayers.maskTool.resize(width, height);
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
|
||||
this.canvasLayers.canvasLayers.width = width;
|
||||
this.canvasLayers.canvasLayers.height = height;
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
|
||||
this.canvasLayers.render();
|
||||
this.canvas.render();
|
||||
|
||||
if (saveHistory) {
|
||||
this.canvasLayers.saveStateToDB();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
}
|
||||
|
||||
addMattedLayer(image, mask) {
|
||||
const layer = {
|
||||
image: image,
|
||||
mask: mask,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvasLayers.layers.length
|
||||
};
|
||||
|
||||
this.canvasLayers.layers.push(layer);
|
||||
this.canvasLayers.selectedLayer = layer;
|
||||
this.canvasLayers.render();
|
||||
}
|
||||
|
||||
isRotationHandle(x, y) {
|
||||
if (!this.canvasLayers.selectedLayer) return false;
|
||||
|
||||
const handleX = this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width / 2;
|
||||
const handleY = this.canvasLayers.selectedLayer.y - 20;
|
||||
const handleRadius = 5;
|
||||
|
||||
return Math.sqrt(Math.pow(x - handleX, 2) + Math.pow(y - handleY, 2)) <= handleRadius;
|
||||
}
|
||||
|
||||
getHandles(layer) {
|
||||
if (!layer) return {};
|
||||
|
||||
@@ -408,7 +421,7 @@ export class CanvasLayers {
|
||||
'sw': {x: -halfW, y: halfH},
|
||||
'w': {x: -halfW, y: 0},
|
||||
'nw': {x: -halfW, y: -halfH},
|
||||
'rot': {x: 0, y: -halfH - 20 / this.canvasLayers.viewport.zoom}
|
||||
'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom}
|
||||
};
|
||||
|
||||
const worldHandles = {};
|
||||
@@ -423,11 +436,11 @@ export class CanvasLayers {
|
||||
}
|
||||
|
||||
getHandleAtPosition(worldX, worldY) {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) return null;
|
||||
if (this.canvas.selectedLayers.length === 0) return null;
|
||||
|
||||
const handleRadius = 8 / this.canvasLayers.viewport.zoom;
|
||||
for (let i = this.canvasLayers.selectedLayers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvasLayers.selectedLayers[i];
|
||||
const handleRadius = 8 / this.canvas.viewport.zoom;
|
||||
for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.selectedLayers[i];
|
||||
const handles = this.getHandles(layer);
|
||||
|
||||
for (const key in handles) {
|
||||
@@ -442,34 +455,6 @@ export class CanvasLayers {
|
||||
return null;
|
||||
}
|
||||
|
||||
getResizeHandle(x, y) {
|
||||
if (!this.canvasLayers.selectedLayer) return null;
|
||||
|
||||
const handleRadius = 5;
|
||||
const handles = {
|
||||
'nw': {x: this.canvasLayers.selectedLayer.x, y: this.canvasLayers.selectedLayer.y},
|
||||
'ne': {
|
||||
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
|
||||
y: this.canvasLayers.selectedLayer.y
|
||||
},
|
||||
'se': {
|
||||
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
|
||||
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
|
||||
},
|
||||
'sw': {
|
||||
x: this.canvasLayers.selectedLayer.x,
|
||||
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
|
||||
}
|
||||
};
|
||||
|
||||
for (const [position, point] of Object.entries(handles)) {
|
||||
if (Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)) <= handleRadius) {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
showBlendModeMenu(x, y) {
|
||||
this.closeBlendModeMenu();
|
||||
|
||||
@@ -482,11 +467,69 @@ export class CanvasLayers {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
`;
|
||||
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.style.cssText = `
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
`;
|
||||
titleBar.textContent = 'Blend Mode';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
padding: 5px;
|
||||
`;
|
||||
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
|
||||
let isDragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (isDragging) {
|
||||
const newX = e.clientX - dragOffset.x;
|
||||
const newY = e.clientY - dragOffset.y;
|
||||
|
||||
const maxX = window.innerWidth - menu.offsetWidth;
|
||||
const maxY = window.innerHeight - menu.offsetHeight;
|
||||
|
||||
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
|
||||
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
titleBar.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
|
||||
dragOffset.x = e.clientX - parseInt(menu.style.left);
|
||||
dragOffset.y = e.clientY - parseInt(menu.style.top);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
this.blendModes.forEach(mode => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'blend-mode-container';
|
||||
@@ -508,58 +551,58 @@ export class CanvasLayers {
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
|
||||
slider.value = this.canvasLayers.selectedLayer.opacity ? Math.round(this.canvasLayers.selectedLayer.opacity * 100) : 100;
|
||||
slider.value = this.canvas.selectedLayer.opacity ? Math.round(this.canvas.selectedLayer.opacity * 100) : 100;
|
||||
slider.style.cssText = `
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
`;
|
||||
|
||||
if (this.canvasLayers.selectedLayer.blendMode === mode.name) {
|
||||
if (this.canvas.selectedLayer.blendMode === mode.name) {
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
}
|
||||
|
||||
option.onclick = () => {
|
||||
menu.querySelectorAll('input[type="range"]').forEach(s => {
|
||||
content.querySelectorAll('input[type="range"]').forEach(s => {
|
||||
s.style.display = 'none';
|
||||
});
|
||||
menu.querySelectorAll('.blend-mode-container div').forEach(d => {
|
||||
content.querySelectorAll('.blend-mode-container div').forEach(d => {
|
||||
d.style.backgroundColor = '';
|
||||
});
|
||||
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
|
||||
if (this.canvasLayers.selectedLayer) {
|
||||
this.canvasLayers.selectedLayer.blendMode = mode.name;
|
||||
this.canvasLayers.render();
|
||||
if (this.canvas.selectedLayer) {
|
||||
this.canvas.selectedLayer.blendMode = mode.name;
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
if (this.canvasLayers.selectedLayer) {
|
||||
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
|
||||
this.canvasLayers.render();
|
||||
if (this.canvas.selectedLayer) {
|
||||
this.canvas.selectedLayer.opacity = slider.value / 100;
|
||||
this.canvas.render();
|
||||
}
|
||||
});
|
||||
|
||||
slider.addEventListener('change', async () => {
|
||||
if (this.canvasLayers.selectedLayer) {
|
||||
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
|
||||
this.canvasLayers.render();
|
||||
if (this.canvas.selectedLayer) {
|
||||
this.canvas.selectedLayer.opacity = slider.value / 100;
|
||||
this.canvas.render();
|
||||
const saveWithFallback = async (fileName) => {
|
||||
try {
|
||||
const uniqueFileName = generateUniqueFileName(fileName, this.canvasLayers.node.id);
|
||||
return await this.canvasLayers.saveToServer(uniqueFileName);
|
||||
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
|
||||
return await this.canvas.saveToServer(uniqueFileName);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
|
||||
return await this.canvasLayers.saveToServer(fileName);
|
||||
return await this.canvas.saveToServer(fileName);
|
||||
}
|
||||
};
|
||||
|
||||
await saveWithFallback(this.canvasLayers.widget.value);
|
||||
if (this.canvasLayers.node) {
|
||||
await saveWithFallback(this.canvas.widget.value);
|
||||
if (this.canvas.node) {
|
||||
app.graph.runStep();
|
||||
}
|
||||
}
|
||||
@@ -567,14 +610,14 @@ export class CanvasLayers {
|
||||
|
||||
container.appendChild(option);
|
||||
container.appendChild(slider);
|
||||
menu.appendChild(container);
|
||||
content.appendChild(container);
|
||||
});
|
||||
|
||||
const container = this.canvasLayers.canvas.parentElement || document.body;
|
||||
const container = this.canvas.canvas.parentElement || document.body;
|
||||
container.appendChild(menu);
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
if (!menu.contains(e.target) && !isDragging) {
|
||||
this.closeBlendModeMenu();
|
||||
document.removeEventListener('mousedown', closeMenu);
|
||||
}
|
||||
@@ -591,17 +634,6 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
|
||||
handleBlendModeSelection(mode) {
|
||||
if (this.selectedBlendMode === mode && !this.isAdjustingOpacity) {
|
||||
this.applyBlendMode(mode, this.blendOpacity);
|
||||
this.closeBlendModeMenu();
|
||||
} else {
|
||||
this.selectedBlendMode = mode;
|
||||
this.isAdjustingOpacity = true;
|
||||
this.showOpacitySlider(mode);
|
||||
}
|
||||
}
|
||||
|
||||
showOpacitySlider(mode) {
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
@@ -623,11 +655,11 @@ export class CanvasLayers {
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvasLayers.width;
|
||||
tempCanvas.height = this.canvasLayers.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const sortedLayers = [...this.canvasLayers.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
@@ -660,14 +692,216 @@ export class CanvasLayers {
|
||||
});
|
||||
}
|
||||
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
tempCtx.translate(centerX, centerY);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(
|
||||
layer.image,
|
||||
-layer.width / 2,
|
||||
-layer.height / 2,
|
||||
layer.width,
|
||||
layer.height
|
||||
);
|
||||
|
||||
tempCtx.restore();
|
||||
});
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(
|
||||
toolMaskCanvas.width - sourceX, // Available width in source
|
||||
this.canvas.width - destX // Available width in destination
|
||||
);
|
||||
const copyHeight = Math.min(
|
||||
toolMaskCanvas.height - sourceY, // Available height in source
|
||||
this.canvas.height - destY // Available height in destination
|
||||
);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
|
||||
destX, destY, copyWidth, copyHeight // Destination rectangle
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||
tempMaskData.data[i + 3] = alpha;
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskImageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
const maskAlpha = maskData[i + 3] / 255; // Użyj kanału alpha maski
|
||||
|
||||
|
||||
const invertedMaskAlpha = 1 - maskAlpha;
|
||||
data[i + 3] = originalAlpha * invertedMaskAlpha;
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed.'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async getFlattenedCanvasForMaskEditor() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
tempCtx.translate(centerX, centerY);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(
|
||||
layer.image,
|
||||
-layer.width / 2,
|
||||
-layer.height / 2,
|
||||
layer.width,
|
||||
layer.height
|
||||
);
|
||||
|
||||
tempCtx.restore();
|
||||
});
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
const sourceX = Math.max(0, -maskX);
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX);
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(
|
||||
toolMaskCanvas.width - sourceX,
|
||||
this.canvas.width - destX
|
||||
);
|
||||
const copyHeight = Math.min(
|
||||
toolMaskCanvas.height - sourceY,
|
||||
this.canvas.height - destY
|
||||
);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight,
|
||||
destX, destY, copyWidth, copyHeight
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||
tempMaskData.data[i + 3] = alpha;
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskImageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
const maskAlpha = maskData[i + 3] / 255;
|
||||
|
||||
|
||||
const invertedMaskAlpha = 1 - maskAlpha;
|
||||
data[i + 3] = originalAlpha * invertedMaskAlpha;
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed.'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async getFlattenedSelectionAsBlob() {
|
||||
if (this.canvasLayers.selectedLayers.length === 0) {
|
||||
if (this.canvas.selectedLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.canvasLayers.selectedLayers.forEach(layer => {
|
||||
this.canvas.selectedLayers.forEach(layer => {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
@@ -705,11 +939,11 @@ export class CanvasLayers {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = newWidth;
|
||||
tempCanvas.height = newHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
tempCtx.translate(-minX, -minY);
|
||||
|
||||
const sortedSelection = [...this.canvasLayers.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedSelection.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
|
||||
@@ -301,7 +301,7 @@ export class CanvasRenderer {
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
const handles = this.canvas.getHandles(layer);
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
@@ -216,6 +216,7 @@ export class CanvasState {
|
||||
await setCanvasState(this.canvas.node.id, state);
|
||||
log.info("Canvas state saved to IndexedDB.");
|
||||
this.lastSavedStateSignature = currentStateSignature;
|
||||
this.canvas.render();
|
||||
}, 'CanvasState.saveStateToDB');
|
||||
|
||||
if (immediate) {
|
||||
@@ -292,7 +293,7 @@ export class CanvasState {
|
||||
const clonedCanvas = document.createElement('canvas');
|
||||
clonedCanvas.width = maskCanvas.width;
|
||||
clonedCanvas.height = maskCanvas.height;
|
||||
const clonedCtx = clonedCanvas.getContext('2d');
|
||||
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
this.maskUndoStack.push(clonedCanvas);
|
||||
@@ -352,7 +353,7 @@ export class CanvasState {
|
||||
if (this.maskUndoStack.length > 0) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
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.drawImage(prevState, 0, 0);
|
||||
|
||||
@@ -368,7 +369,7 @@ export class CanvasState {
|
||||
const nextState = this.maskRedoStack.pop();
|
||||
this.maskUndoStack.push(nextState);
|
||||
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.drawImage(nextState, 0, 0);
|
||||
|
||||
|
||||
394
js/CanvasView.js
394
js/CanvasView.js
@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
|
||||
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 {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 45%;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 55%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 9998;
|
||||
z-index: 111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -483,38 +513,31 @@ async function createCanvasWidget(node, widget, app) {
|
||||
} 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';
|
||||
@@ -539,7 +562,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.addLayer(img, addMode);
|
||||
canvas.addLayer(img, {}, addMode);
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
@@ -552,17 +575,116 @@ async function createCanvasWidget(node, widget, app) {
|
||||
$el("button.painter-button.primary", {
|
||||
textContent: "Import Input",
|
||||
title: "Import image from another node",
|
||||
onclick: () => canvas.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);
|
||||
}
|
||||
onclick: () => canvas.canvasIO.importLatestImage()
|
||||
}),
|
||||
$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>
|
||||
`;
|
||||
}
|
||||
|
||||
helpTooltip.innerHTML = tooltipContent;
|
||||
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;
|
||||
|
||||
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';
|
||||
},
|
||||
onmouseleave: () => {
|
||||
helpTooltip.style.display = 'none';
|
||||
}
|
||||
})
|
||||
]),
|
||||
]),
|
||||
|
||||
$el("div.painter-separator"),
|
||||
@@ -644,7 +766,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
||||
canvas.updateOutputAreaSize(width, height);
|
||||
document.body.removeChild(dialog);
|
||||
// updateOutput is triggered by saveState in updateOutputAreaSize
|
||||
|
||||
};
|
||||
|
||||
document.getElementById('cancel-size').onclick = () => {
|
||||
@@ -660,12 +782,12 @@ async function createCanvasWidget(node, widget, app) {
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Layer Up",
|
||||
title: "Move selected layer(s) up",
|
||||
onclick: () => canvas.moveLayerUp()
|
||||
onclick: () => canvas.canvasLayers.moveLayerUp()
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Layer Down",
|
||||
title: "Move selected layer(s) down",
|
||||
onclick: () => canvas.moveLayerDown()
|
||||
onclick: () => canvas.canvasLayers.moveLayerDown()
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -674,27 +796,27 @@ async function createCanvasWidget(node, widget, app) {
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Rotate +90°",
|
||||
title: "Rotate selected layer(s) by +90 degrees",
|
||||
onclick: () => canvas.rotateLayer(90)
|
||||
onclick: () => canvas.canvasLayers.rotateLayer(90)
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Scale +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", {
|
||||
textContent: "Scale -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", {
|
||||
textContent: "Mirror H",
|
||||
title: "Mirror selected layer(s) horizontally",
|
||||
onclick: () => canvas.mirrorHorizontal()
|
||||
onclick: () => canvas.canvasLayers.mirrorHorizontal()
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Mirror V",
|
||||
title: "Mirror selected layer(s) vertically",
|
||||
onclick: () => canvas.mirrorVertical()
|
||||
onclick: () => canvas.canvasLayers.mirrorVertical()
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -716,7 +838,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
|
||||
const selectedLayer = canvas.selectedLayers[0];
|
||||
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
||||
const imageData = await canvas.getLayerImageData(selectedLayer);
|
||||
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
|
||||
const response = await fetch("/matting", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
@@ -749,18 +871,25 @@ async function createCanvasWidget(node, widget, app) {
|
||||
textContent: "Undo",
|
||||
title: "Undo last action",
|
||||
disabled: true,
|
||||
onclick: () => canvas.undo()
|
||||
onclick: () => canvas.canvasState.undo()
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
id: `redo-button-${node.id}`,
|
||||
textContent: "Redo",
|
||||
title: "Redo last undone action",
|
||||
disabled: true,
|
||||
onclick: () => canvas.redo()
|
||||
onclick: () => canvas.canvasState.redo()
|
||||
}),
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$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", {
|
||||
id: "mask-mode-btn",
|
||||
textContent: "Draw Mask",
|
||||
@@ -838,15 +967,15 @@ async function createCanvasWidget(node, widget, app) {
|
||||
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
||||
onclick: async () => {
|
||||
try {
|
||||
const stats = canvas.getGarbageCollectionStats();
|
||||
const stats = canvas.imageReferenceManager.getStats();
|
||||
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);
|
||||
|
||||
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) {
|
||||
log.error("Failed to run garbage collection:", e);
|
||||
alert("Error running garbage collection. Check the console for details.");
|
||||
@@ -910,9 +1039,23 @@ async function createCanvasWidget(node, widget, app) {
|
||||
|
||||
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
||||
|
||||
const updateOutput = () => {
|
||||
const updateOutput = async () => {
|
||||
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);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||
@@ -948,70 +1091,8 @@ async function createCanvasWidget(node, widget, app) {
|
||||
height: "100%"
|
||||
}
|
||||
}, [controlPanel, canvasContainer]);
|
||||
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);
|
||||
|
||||
@@ -1072,14 +1153,34 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (!window.canvasExecutionStates) {
|
||||
window.canvasExecutionStates = new Map();
|
||||
}
|
||||
|
||||
|
||||
node.canvasWidget = canvas;
|
||||
|
||||
setTimeout(() => {
|
||||
canvas.loadInitialState();
|
||||
}, 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 {
|
||||
canvas: canvas,
|
||||
panel: controlPanel
|
||||
@@ -1154,7 +1255,6 @@ app.registerExtension({
|
||||
return;
|
||||
}
|
||||
|
||||
// Iterate through every widget attached to this node
|
||||
this.widgets.forEach(w => {
|
||||
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
||||
});
|
||||
@@ -1206,7 +1306,32 @@ app.registerExtension({
|
||||
originalGetExtraMenuOptions?.apply(this, arguments);
|
||||
|
||||
const self = this;
|
||||
|
||||
const maskEditorIndex = options.findIndex(option =>
|
||||
option && option.content === "Open in MaskEditor"
|
||||
);
|
||||
if (maskEditorIndex !== -1) {
|
||||
options.splice(maskEditorIndex, 1);
|
||||
}
|
||||
|
||||
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",
|
||||
callback: async () => {
|
||||
@@ -1220,6 +1345,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",
|
||||
callback: async () => {
|
||||
@@ -1234,6 +1372,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",
|
||||
callback: async () => {
|
||||
@@ -1252,6 +1404,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) {
|
||||
options.unshift({content: "___", disabled: true});
|
||||
|
||||
@@ -8,7 +8,7 @@ export class MaskTool {
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
this.maskCanvas = document.createElement('canvas');
|
||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
||||
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
@@ -21,7 +21,7 @@ export class MaskTool {
|
||||
this.lastPosition = null;
|
||||
|
||||
this.previewCanvas = document.createElement('canvas');
|
||||
this.previewCtx = this.previewCanvas.getContext('2d');
|
||||
this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
||||
this.previewVisible = false;
|
||||
this.previewCanvasInitialized = false;
|
||||
|
||||
@@ -162,7 +162,7 @@ export class MaskTool {
|
||||
if (this.brushHardness === 1) {
|
||||
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
} else {
|
||||
// hardness: 1 = hard edge, 0 = soft edge
|
||||
|
||||
const innerRadius = gradientRadius * this.brushHardness;
|
||||
const gradient = this.maskCtx.createRadialGradient(
|
||||
canvasX, canvasY, innerRadius,
|
||||
@@ -220,7 +220,7 @@ export class MaskTool {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.maskCanvas.width;
|
||||
tempCanvas.height = this.maskCanvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
@@ -258,7 +258,7 @@ export class MaskTool {
|
||||
|
||||
this.maskCanvas.width = newWidth;
|
||||
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) {
|
||||
|
||||
@@ -279,4 +279,23 @@ export class MaskTool {
|
||||
this.y += dy;
|
||||
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}).`);
|
||||
}
|
||||
}
|
||||
|
||||
510
js/utils/ClipboardManager.js
Normal file
510
js/utils/ClipboardManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
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);
|
||||
return v.toString(16);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
||||
top: layer.y,
|
||||
bottom: layer.y + layer.height
|
||||
};
|
||||
|
||||
|
||||
const x_adjustments = [
|
||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||
{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.bottom, gridSize) - layerEdges.bottom}
|
||||
];
|
||||
|
||||
|
||||
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
|
||||
|
||||
const bestXSnap = x_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
const bestYSnap = y_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
|
||||
|
||||
return {
|
||||
dx: bestXSnap ? bestXSnap.delta : 0,
|
||||
dy: bestYSnap ? bestYSnap.delta : 0
|
||||
@@ -145,7 +145,7 @@ export function getStateSignature(layers) {
|
||||
if (layer.image && layer.image.src) {
|
||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||
}
|
||||
|
||||
|
||||
return sig;
|
||||
}));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export function debounce(func, wait, immediate) {
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
@@ -241,7 +241,7 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
|
||||
if (width) canvas.width = width;
|
||||
if (height) canvas.height = height;
|
||||
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) {
|
||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
|
||||
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);
|
||||
|
||||
if (Array.isArray(inputImage)) {
|
||||
@@ -122,7 +123,7 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
||||
}
|
||||
|
||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||
throw createValidationError("Invalid input image format", { inputImage });
|
||||
throw createValidationError("Invalid input image format", {inputImage});
|
||||
}
|
||||
|
||||
const shape = inputImage.shape;
|
||||
@@ -161,29 +162,29 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||
* @returns {Promise<Object>} Tensor z danymi obrazu
|
||||
*/
|
||||
export const imageToTensor = withErrorHandling(async function(image) {
|
||||
export const imageToTensor = withErrorHandling(async function (image) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
data: data,
|
||||
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
|
||||
* @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) {
|
||||
throw createValidationError("Invalid tensor format", { tensor });
|
||||
throw createValidationError("Invalid tensor format", {tensor});
|
||||
}
|
||||
|
||||
const [, height, width, channels] = tensor.shape;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
@@ -239,27 +240,27 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
|
||||
* @param {number} maxHeight - Maksymalna wysokość
|
||||
* @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) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const originalWidth = image.width || image.naturalWidth;
|
||||
const originalHeight = image.height || image.naturalHeight;
|
||||
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
||||
const newWidth = Math.round(originalWidth * scale);
|
||||
const newHeight = Math.round(originalHeight * scale);
|
||||
|
||||
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
@@ -274,7 +275,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
|
||||
* @param {number} size - Rozmiar miniatury (kwadrat)
|
||||
* @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);
|
||||
}, 'createThumbnail');
|
||||
|
||||
@@ -285,19 +286,19 @@ export const createThumbnail = withErrorHandling(async function(image, size = 12
|
||||
* @param {number} quality - Jakość (0-1) dla formatów stratnych
|
||||
* @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) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
}, 'imageToBase64');
|
||||
@@ -307,7 +308,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
|
||||
* @param {string} base64 - Base64 string
|
||||
* @returns {Promise<HTMLImageElement>} Obraz
|
||||
*/
|
||||
export const base64ToImage = withErrorHandling(function(base64) {
|
||||
export const base64ToImage = withErrorHandling(function (base64) {
|
||||
if (!base64) {
|
||||
throw createValidationError("Base64 string is required");
|
||||
}
|
||||
@@ -326,10 +327,10 @@ export const base64ToImage = withErrorHandling(function(base64) {
|
||||
* @returns {boolean} Czy obraz jest prawidłowy
|
||||
*/
|
||||
export function isValidImage(image) {
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,18 +372,18 @@ export function createImageFromSource(source) {
|
||||
* @param {string} color - Kolor tła (CSS color)
|
||||
* @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 ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {logger, LogLevel} from "../logger.js";
|
||||
*/
|
||||
export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
||||
logger.setModuleLevel(moduleName, level);
|
||||
|
||||
|
||||
return {
|
||||
debug: (...args) => logger.debug(moduleName, ...args),
|
||||
info: (...args) => logger.info(moduleName, ...args),
|
||||
@@ -31,7 +31,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
|
||||
const stack = new Error().stack;
|
||||
const match = stack.match(/\/([^\/]+)\.js/);
|
||||
const moduleName = match ? match[1] : 'Unknown';
|
||||
|
||||
|
||||
return createModuleLogger(moduleName, level);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorLogging(operation, log, operationName) {
|
||||
return async function(...args) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
log.debug(`Starting ${operationName}`);
|
||||
const result = await operation.apply(this, args);
|
||||
@@ -62,10 +62,10 @@ export function withErrorLogging(operation, log, operationName) {
|
||||
* @param {string} methodName - Nazwa metody
|
||||
*/
|
||||
export function logMethod(log, methodName) {
|
||||
return function(target, propertyKey, descriptor) {
|
||||
return function (target, propertyKey, descriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function(...args) {
|
||||
|
||||
descriptor.value = async function (...args) {
|
||||
try {
|
||||
log.debug(`${methodName || propertyKey} started`);
|
||||
const result = await originalMethod.apply(this, args);
|
||||
@@ -76,7 +76,7 @@ export function logMethod(log, methodName) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class WebSocketManager {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log.debug("Received message:", data);
|
||||
|
||||
|
||||
if (data.type === 'ack' && data.nodeId) {
|
||||
const callback = this.ackCallbacks.get(data.nodeId);
|
||||
if (callback) {
|
||||
@@ -130,7 +130,6 @@ class WebSocketManager {
|
||||
log.warn("WebSocket not open. Queuing message.");
|
||||
|
||||
|
||||
|
||||
this.messageQueue.push(message);
|
||||
if (!this.isConnecting) {
|
||||
this.connect();
|
||||
@@ -147,7 +146,6 @@ class WebSocketManager {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
this.socket.send(message);
|
||||
|
||||
174
js/utils/mask_utils.js
Normal file
174
js/utils/mask_utils.js
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "layerforge"
|
||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||
version = "1.2.4"
|
||||
version = "1.3.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user