mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 21:12:12 -03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffbd5bfe43 | ||
|
|
da75a427fa | ||
|
|
4e1be7c1a3 | ||
|
|
bccb9da641 | ||
|
|
5235f7b961 | ||
|
|
ab4a8f7ca7 | ||
|
|
472f8768a5 | ||
|
|
1d520eca01 | ||
|
|
784e3d9296 | ||
|
|
eaf9c28ef0 | ||
|
|
133b009086 | ||
|
|
fe75968e13 | ||
|
|
0f8db35d52 | ||
|
|
ef4e65cb78 | ||
|
|
8e38ec98dd | ||
|
|
f3027587d6 | ||
|
|
20d52b632a | ||
|
|
57bd1e1499 | ||
|
|
674879b497 | ||
|
|
98d4769ba1 | ||
|
|
5419acad27 | ||
|
|
db65c0c72e |
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
76
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,19 +7,46 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Thank you for reporting a bug!**
|
**Thank you for reporting a bug!**
|
||||||
Please follow these steps to capture useful info:
|
Please follow these steps to capture all necessary information:
|
||||||
|
|
||||||
### How to gather the necessary information:
|
### ✅ Before You Report:
|
||||||
🌐 **Browser & Version:**
|
1. Make sure you have the **latest versions**:
|
||||||
- Chrome: Click the three dots → Help → About Google Chrome
|
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
|
||||||
- Firefox: Click the three bars → Help → About Firefox
|
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
||||||
- Edge: Click the three dots → Help and feedback → About Microsoft Edge
|
2. Gather the required logs:
|
||||||
|
|
||||||
|
### 🔍 Enable Debug Logs (for **full** logs):
|
||||||
|
|
||||||
|
#### 1. Edit `config.js` (Frontend Logs):
|
||||||
|
Path:
|
||||||
|
```
|
||||||
|
ComfyUI/custom_nodes/Comfyui-LayerForge/js/config.js
|
||||||
|
```
|
||||||
|
Find:
|
||||||
|
```js
|
||||||
|
export const LOG_LEVEL = 'NONE';
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```js
|
||||||
|
export const LOG_LEVEL = 'DEBUG';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Edit `config.py` (Backend Logs):
|
||||||
|
Path:
|
||||||
|
```
|
||||||
|
ComfyUI/custom_nodes/Comfyui-LayerForge/python/config.py
|
||||||
|
```
|
||||||
|
Find:
|
||||||
|
```python
|
||||||
|
LOG_LEVEL = 'NONE'
|
||||||
|
```
|
||||||
|
Change to:
|
||||||
|
```python
|
||||||
|
LOG_LEVEL = 'DEBUG'
|
||||||
|
```
|
||||||
|
|
||||||
🔗 **Where to find the latest versions of ComfyUI and LayerForge:**
|
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
|
||||||
- [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
|
- type: input
|
||||||
id: environment
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
@@ -71,23 +98,36 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: backend_logs
|
||||||
|
attributes:
|
||||||
|
label: ComfyUI (Backend) Logs
|
||||||
|
description: |
|
||||||
|
After enabling DEBUG logs, please:
|
||||||
|
1. Restart ComfyUI.
|
||||||
|
2. Reproduce the issue.
|
||||||
|
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: console_logs
|
id: console_logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Browser Console Logs
|
label: Browser Console Logs
|
||||||
description: |
|
description: |
|
||||||
**How to capture logs:**
|
After enabling DEBUG logs:
|
||||||
- **Open console:**
|
1. Open Developer Tools → Console.
|
||||||
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
||||||
Mac: `Cmd+Option+J`
|
Mac: `Cmd+Option+J`
|
||||||
- Firefox (Win/Linux): `Ctrl+Shift+K`
|
- Firefox (Win/Linux): `Ctrl+Shift+K`
|
||||||
Mac: `Cmd+Option+K`
|
Mac: `Cmd+Option+K`
|
||||||
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
|
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
|
||||||
- **Clear console** before reproducing:
|
2. Clear console (before reproducing):
|
||||||
- Chrome/Edge: click “🚫 Clear console” or press `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac)
|
- Chrome/Edge: “🚫 Clear console” or `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac).
|
||||||
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux), Mac: `Cmd+K`
|
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux) / `Cmd+K` (Mac).
|
||||||
- Safari: click 🗑 icon or press `Cmd+K` / `Ctrl+L`
|
- Safari: 🗑 icon or `Cmd+K`.
|
||||||
- Reproduce the issue and paste new logs here.
|
3. Reproduce the issue.
|
||||||
|
4. Copy-paste the **TEXT** logs here (no screenshots).
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -95,4 +135,4 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
**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.
|
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
||||||
|
|||||||
5
.github/workflows/publish.yml
vendored
5
.github/workflows/publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- master
|
|
||||||
paths:
|
paths:
|
||||||
- "pyproject.toml"
|
- "pyproject.toml"
|
||||||
|
|
||||||
@@ -19,10 +18,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Publish Custom Node
|
- name: Publish Custom Node
|
||||||
uses: Comfy-Org/publish-node-action@v1
|
uses: Comfy-Org/publish-node-action@main
|
||||||
with:
|
with:
|
||||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@@ -12,6 +12,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Pobierz pełną historię Git (potrzebne do git log)
|
||||||
|
|
||||||
- name: Extract base version from pyproject.toml
|
- name: Extract base version from pyproject.toml
|
||||||
id: version
|
id: version
|
||||||
@@ -33,12 +35,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Get latest commit message
|
# ZMIANA: Poprawione obsługa multi-line output (z delimiterem EOF, bez zastępowania \n)
|
||||||
id: last_commit
|
- name: Get commit history since last tag
|
||||||
|
id: commit_history
|
||||||
run: |
|
run: |
|
||||||
msg=$(git log -1 --pretty=%B)
|
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
msg=${msg//$'\n'/\\n}
|
|
||||||
echo "commit_msg=$msg" >> $GITHUB_OUTPUT
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
RANGE="HEAD"
|
||||||
|
else
|
||||||
|
RANGE="$LAST_TAG..HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pobierz listę commitów i przefiltruj tylko te znaczące
|
||||||
|
HISTORY=$(git log --pretty=format:"%s" $RANGE | \
|
||||||
|
grep -vE '^\s*(add|update|fix|change|edit|mod|modify|cleanup|misc|typo|readme|temp|test|debug)\b' | \
|
||||||
|
grep -vE '^(\s*Update|Add|Fix|Change|Edit|Refactor|Bump|Minor|Misc|Readme|Test)[^a-zA-Z0-9]*$' | \
|
||||||
|
sed 's/^/- /')
|
||||||
|
|
||||||
|
if [ -z "$HISTORY" ]; then
|
||||||
|
HISTORY="No significant changes since last release."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "commit_history<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$HISTORY" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -48,9 +69,9 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
||||||
|
|
||||||
📝 Last commit message:
|
📝 Changes since last release:
|
||||||
```
|
```
|
||||||
${{ steps.last_commit.outputs.commit_msg }}
|
${{ steps.commit_history.outputs.commit_history }}
|
||||||
```
|
```
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -175,8 +175,9 @@ class CanvasNode:
|
|||||||
"required": {
|
"required": {
|
||||||
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
||||||
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
|
"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}),
|
"auto_refresh_after_generation": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}),
|
||||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||||
|
"node_id": ("STRING", {"default": "0"}),
|
||||||
},
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"prompt": ("PROMPT",),
|
"prompt": ("PROMPT",),
|
||||||
@@ -238,7 +239,7 @@ class CanvasNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_processing_lock = threading.Lock()
|
||||||
|
|
||||||
def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None):
|
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -391,7 +392,7 @@ class CanvasNode:
|
|||||||
def setup_routes(cls):
|
def setup_routes(cls):
|
||||||
@PromptServer.instance.routes.get("/layerforge/canvas_ws")
|
@PromptServer.instance.routes.get("/layerforge/canvas_ws")
|
||||||
async def handle_canvas_websocket(request):
|
async def handle_canvas_websocket(request):
|
||||||
ws = web.WebSocketResponse()
|
ws = web.WebSocketResponse(max_msg_size=33554432)
|
||||||
await ws.prepare(request)
|
await ws.prepare(request)
|
||||||
|
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
|
|||||||
26
js/Canvas.js
26
js/Canvas.js
@@ -54,7 +54,8 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
this.offscreenCanvas = document.createElement('canvas');
|
this.offscreenCanvas = document.createElement('canvas');
|
||||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||||
alpha: false
|
alpha: false,
|
||||||
|
willReadFrequently: true
|
||||||
});
|
});
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
@@ -83,7 +84,7 @@ export class Canvas {
|
|||||||
dimensions: { width: this.width, height: this.height },
|
dimensions: { width: this.width, height: this.height },
|
||||||
viewport: this.viewport
|
viewport: this.viewport
|
||||||
});
|
});
|
||||||
this.setPreviewVisibility(false);
|
this.previewVisible = false;
|
||||||
}
|
}
|
||||||
async waitForWidget(name, node, interval = 100, timeout = 20000) {
|
async waitForWidget(name, node, interval = 100, timeout = 20000) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -155,7 +156,7 @@ export class Canvas {
|
|||||||
log.debug('Initializing Canvas modules...');
|
log.debug('Initializing Canvas modules...');
|
||||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||||
this._addAutoRefreshToggle();
|
this._setupAutoRefreshHandlers();
|
||||||
log.debug('Canvas modules initialized successfully');
|
log.debug('Canvas modules initialized successfully');
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -321,11 +322,15 @@ export class Canvas {
|
|||||||
async importLatestImage() {
|
async importLatestImage() {
|
||||||
return this.canvasIO.importLatestImage();
|
return this.canvasIO.importLatestImage();
|
||||||
}
|
}
|
||||||
_addAutoRefreshToggle() {
|
_setupAutoRefreshHandlers() {
|
||||||
let autoRefreshEnabled = false;
|
|
||||||
let lastExecutionStartTime = 0;
|
let lastExecutionStartTime = 0;
|
||||||
|
// Helper function to get auto-refresh value from node widget
|
||||||
|
const getAutoRefreshValue = () => {
|
||||||
|
const widget = this.node.widgets.find((w) => w.name === 'auto_refresh_after_generation');
|
||||||
|
return widget ? widget.value : false;
|
||||||
|
};
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
if (autoRefreshEnabled) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
this.pendingBatchContext = {
|
this.pendingBatchContext = {
|
||||||
@@ -347,7 +352,7 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
if (autoRefreshEnabled) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
if (!this.pendingBatchContext) {
|
if (!this.pendingBatchContext) {
|
||||||
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||||
@@ -366,12 +371,6 @@ export class Canvas {
|
|||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.node.addWidget('toggle', 'Auto-refresh after generation', false, (value) => {
|
|
||||||
autoRefreshEnabled = value;
|
|
||||||
log.debug('Auto-refresh toggled:', value);
|
|
||||||
}, {
|
|
||||||
serialize: false
|
|
||||||
});
|
|
||||||
api.addEventListener('execution_start', handleExecutionStart);
|
api.addEventListener('execution_start', handleExecutionStart);
|
||||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||||
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
||||||
@@ -379,6 +378,7 @@ export class Canvas {
|
|||||||
api.removeEventListener('execution_start', handleExecutionStart);
|
api.removeEventListener('execution_start', handleExecutionStart);
|
||||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||||
});
|
});
|
||||||
|
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Uruchamia edytor masek
|
* Uruchamia edytor masek
|
||||||
|
|||||||
@@ -62,25 +62,9 @@ export class CanvasIO {
|
|||||||
maskCtx.fillStyle = '#ffffff';
|
maskCtx.fillStyle = '#ffffff';
|
||||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||||
log.debug(`Processing ${sortedLayers.length} layers in order`);
|
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||||
sortedLayers.forEach((layer, index) => {
|
log.debug(`Finished rendering layers`);
|
||||||
log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`);
|
|
||||||
log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
||||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
tempCtx.restore();
|
|
||||||
log.debug(`Layer ${index} rendered successfully`);
|
|
||||||
visibilityCtx.save();
|
|
||||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
visibilityCtx.restore();
|
|
||||||
});
|
|
||||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||||
@@ -232,21 +216,8 @@ export class CanvasIO {
|
|||||||
throw new Error("Could not create temp context");
|
throw new Error("Could not create temp context");
|
||||||
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
||||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||||
sortedLayers.forEach((layer) => {
|
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
||||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
tempCtx.restore();
|
|
||||||
visibilityCtx.save();
|
|
||||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
visibilityCtx.restore();
|
|
||||||
});
|
|
||||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||||
|
// Add a blur event listener to the window to reset key states
|
||||||
|
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.isMouseOver = true;
|
||||||
@@ -373,6 +375,23 @@ export class CanvasInteractions {
|
|||||||
this.interaction.keyMovementInProgress = false;
|
this.interaction.keyMovementInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
handleBlur() {
|
||||||
|
log.debug('Window lost focus, resetting key states.');
|
||||||
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isAltPressed = false;
|
||||||
|
this.interaction.keyMovementInProgress = false;
|
||||||
|
// Also reset any interaction that relies on a key being held down
|
||||||
|
if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) {
|
||||||
|
// If we were in the middle of a cloning drag, finalize it
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.canvasState.saveStateToDB();
|
||||||
|
}
|
||||||
|
// Reset interaction mode if it's something that can get "stuck"
|
||||||
|
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
|
||||||
|
this.resetInteractionState();
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
updateCursor(worldCoords) {
|
updateCursor(worldCoords) {
|
||||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
|
|||||||
@@ -303,72 +303,68 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
_drawLayer(ctx, layer, options = {}) {
|
||||||
|
if (!layer.image)
|
||||||
|
return;
|
||||||
|
const { offsetX = 0, offsetY = 0 } = options;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
|
const centerX = layer.x + layer.width / 2 - offsetX;
|
||||||
|
const centerY = layer.y + layer.height / 2 - offsetY;
|
||||||
|
ctx.translate(centerX, centerY);
|
||||||
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
const scaleH = layer.flipH ? -1 : 1;
|
||||||
|
const scaleV = layer.flipV ? -1 : 1;
|
||||||
|
if (layer.flipH || layer.flipV) {
|
||||||
|
ctx.scale(scaleH, scaleV);
|
||||||
|
}
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
_drawLayers(ctx, layers, options = {}) {
|
||||||
|
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
|
||||||
|
}
|
||||||
|
drawLayersToContext(ctx, layers, options = {}) {
|
||||||
|
this._drawLayers(ctx, layers, options);
|
||||||
|
}
|
||||||
async mirrorHorizontal() {
|
async mirrorHorizontal() {
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
||||||
return;
|
return;
|
||||||
const promises = this.canvas.canvasSelection.selectedLayers.map((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
return new Promise(resolve => {
|
layer.flipH = !layer.flipH;
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
|
||||||
if (!tempCtx)
|
|
||||||
return;
|
|
||||||
tempCanvas.width = layer.image.width;
|
|
||||||
tempCanvas.height = layer.image.height;
|
|
||||||
tempCtx.translate(tempCanvas.width, 0);
|
|
||||||
tempCtx.scale(-1, 1);
|
|
||||||
tempCtx.drawImage(layer.image, 0, 0);
|
|
||||||
const newImage = new Image();
|
|
||||||
newImage.onload = () => {
|
|
||||||
layer.image = newImage;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
newImage.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await Promise.all(promises);
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
async mirrorVertical() {
|
async mirrorVertical() {
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
if (this.canvas.canvasSelection.selectedLayers.length === 0)
|
||||||
return;
|
return;
|
||||||
const promises = this.canvas.canvasSelection.selectedLayers.map((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
return new Promise(resolve => {
|
layer.flipV = !layer.flipV;
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
|
||||||
if (!tempCtx)
|
|
||||||
return;
|
|
||||||
tempCanvas.width = layer.image.width;
|
|
||||||
tempCanvas.height = layer.image.height;
|
|
||||||
tempCtx.translate(0, tempCanvas.height);
|
|
||||||
tempCtx.scale(1, -1);
|
|
||||||
tempCtx.drawImage(layer.image, 0, 0);
|
|
||||||
const newImage = new Image();
|
|
||||||
newImage.onload = () => {
|
|
||||||
layer.image = newImage;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
newImage.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await Promise.all(promises);
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
async getLayerImageData(layer) {
|
async getLayerImageData(layer) {
|
||||||
try {
|
try {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx)
|
if (!tempCtx)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Could not create canvas context");
|
||||||
tempCanvas.width = layer.width;
|
tempCanvas.width = layer.width;
|
||||||
tempCanvas.height = layer.height;
|
tempCanvas.height = layer.height;
|
||||||
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
|
||||||
tempCtx.save();
|
// by creating a temporary layer object for drawing.
|
||||||
tempCtx.translate(layer.width / 2, layer.height / 2);
|
const layerToDraw = {
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
...layer,
|
||||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
x: 0,
|
||||||
tempCtx.restore();
|
y: 0,
|
||||||
|
};
|
||||||
|
this._drawLayer(tempCtx, layerToDraw);
|
||||||
const dataUrl = tempCanvas.toDataURL('image/png');
|
const dataUrl = tempCanvas.toDataURL('image/png');
|
||||||
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
||||||
throw new Error("Invalid image data format");
|
throw new Error("Invalid image data format");
|
||||||
@@ -597,25 +593,12 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.canvas.width;
|
tempCanvas.width = this.canvas.width;
|
||||||
tempCanvas.height = this.canvas.height;
|
tempCanvas.height = this.canvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.layers);
|
||||||
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 imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||||
@@ -623,7 +606,7 @@ export class CanvasLayers {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempMaskCtx) {
|
if (!tempMaskCtx) {
|
||||||
reject(new Error("Could not create mask canvas context"));
|
reject(new Error("Could not create mask canvas context"));
|
||||||
return;
|
return;
|
||||||
@@ -672,25 +655,12 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.canvas.width;
|
tempCanvas.width = this.canvas.width;
|
||||||
tempCanvas.height = this.canvas.height;
|
tempCanvas.height = this.canvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.layers);
|
||||||
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();
|
|
||||||
});
|
|
||||||
tempCanvas.toBlob((blob) => {
|
tempCanvas.toBlob((blob) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
@@ -742,26 +712,13 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = newWidth;
|
tempCanvas.width = newWidth;
|
||||||
tempCanvas.height = newHeight;
|
tempCanvas.height = newHeight;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tempCtx.translate(-minX, -minY);
|
tempCtx.translate(-minX, -minY);
|
||||||
const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||||
sortedSelection.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();
|
|
||||||
});
|
|
||||||
tempCanvas.toBlob((blob) => {
|
tempCanvas.toBlob((blob) => {
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
@@ -809,24 +766,11 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = fusedWidth;
|
tempCanvas.width = fusedWidth;
|
||||||
tempCanvas.height = fusedHeight;
|
tempCanvas.height = fusedHeight;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx)
|
if (!tempCtx)
|
||||||
throw new Error("Could not create canvas context");
|
throw new Error("Could not create canvas context");
|
||||||
tempCtx.translate(-minX, -minY);
|
tempCtx.translate(-minX, -minY);
|
||||||
const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||||
sortedSelection.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 fusedImage = new Image();
|
const fusedImage = new Image();
|
||||||
fusedImage.src = tempCanvas.toDataURL();
|
fusedImage.src = tempCanvas.toDataURL();
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -866,7 +810,7 @@ export class CanvasLayers {
|
|||||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||||
}
|
}
|
||||||
log.info("Layers fused successfully", {
|
log.info("Layers fused successfully", {
|
||||||
originalLayerCount: sortedSelection.length,
|
originalLayerCount: this.canvas.canvasSelection.selectedLayers.length,
|
||||||
fusedDimensions: { width: fusedWidth, height: fusedHeight },
|
fusedDimensions: { width: fusedWidth, height: fusedHeight },
|
||||||
fusedPosition: { x: minX, y: minY }
|
fusedPosition: { x: minX, y: minY }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ export class CanvasRenderer {
|
|||||||
const centerY = layer.y + layer.height / 2;
|
const centerY = layer.y + layer.height / 2;
|
||||||
ctx.translate(centerX, centerY);
|
ctx.translate(centerX, centerY);
|
||||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
const scaleH = layer.flipH ? -1 : 1;
|
||||||
|
const scaleV = layer.flipV ? -1 : 1;
|
||||||
|
if (layer.flipH || layer.flipV) {
|
||||||
|
ctx.scale(scaleH, scaleV);
|
||||||
|
}
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
|||||||
@@ -259,13 +259,15 @@ export class CanvasState {
|
|||||||
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
||||||
delete newLayer.image;
|
delete newLayer.image;
|
||||||
if (layer.image instanceof HTMLImageElement) {
|
if (layer.image instanceof HTMLImageElement) {
|
||||||
log.debug(`Layer ${index}: Using imageId instead of serializing image.`);
|
if (layer.imageId) {
|
||||||
if (!layer.imageId) {
|
newLayer.imageId = layer.imageId;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||||
newLayer.imageId = generateUUID();
|
newLayer.imageId = generateUUID();
|
||||||
const imageBitmap = await createImageBitmap(layer.image);
|
const imageBitmap = await createImageBitmap(layer.image);
|
||||||
await saveImage(newLayer.imageId, imageBitmap);
|
await saveImage(newLayer.imageId, imageBitmap);
|
||||||
}
|
}
|
||||||
newLayer.imageId = layer.imageId;
|
|
||||||
}
|
}
|
||||||
else if (!layer.imageId) {
|
else if (!layer.imageId) {
|
||||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||||
|
|||||||
158
js/CanvasView.js
158
js/CanvasView.js
@@ -7,6 +7,7 @@ import { Canvas } from "./Canvas.js";
|
|||||||
import { clearAllCanvasStates } from "./db.js";
|
import { clearAllCanvasStates } from "./db.js";
|
||||||
import { ImageCache } from "./ImageCache.js";
|
import { ImageCache } from "./ImageCache.js";
|
||||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
|
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||||
const log = createModuleLogger('Canvas_view');
|
const log = createModuleLogger('Canvas_view');
|
||||||
async function createCanvasWidget(node, widget, app) {
|
async function createCanvasWidget(node, widget, app) {
|
||||||
const canvas = new Canvas(node, widget, {
|
const canvas = new Canvas(node, widget, {
|
||||||
@@ -330,7 +331,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const mattedImage = new Image();
|
const mattedImage = new Image();
|
||||||
mattedImage.src = result.matted_image;
|
mattedImage.src = result.matted_image;
|
||||||
await mattedImage.decode();
|
await mattedImage.decode();
|
||||||
const newLayer = { ...selectedLayer, image: mattedImage };
|
const newLayer = { ...selectedLayer, image: mattedImage, flipH: false, flipV: false };
|
||||||
delete newLayer.imageId;
|
delete newLayer.imageId;
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
@@ -530,27 +531,74 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
};
|
};
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
canvas.updateHistoryButtons();
|
canvas.updateHistoryButtons();
|
||||||
|
// Debounce timer for updateOutput to prevent excessive updates
|
||||||
|
let updateOutputTimer = null;
|
||||||
const updateOutput = async (node, canvas) => {
|
const updateOutput = async (node, canvas) => {
|
||||||
|
// Check if preview is disabled - if so, skip updateOutput entirely
|
||||||
|
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
|
||||||
|
if (showPreviewWidget && !showPreviewWidget.value) {
|
||||||
|
log.debug("Preview disabled, skipping updateOutput");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
||||||
if (triggerWidget) {
|
if (triggerWidget) {
|
||||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||||
}
|
}
|
||||||
try {
|
// Clear previous timer
|
||||||
const new_preview = new Image();
|
if (updateOutputTimer) {
|
||||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
clearTimeout(updateOutputTimer);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
// Debounce the update to prevent excessive processing during rapid changes
|
||||||
|
updateOutputTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
// For large images, use blob URL for better performance
|
||||||
|
if (blob.size > 2 * 1024 * 1024) { // 2MB threshold
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
node.imgs = [img];
|
||||||
|
log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`);
|
||||||
|
// Clean up old blob URLs to prevent memory leaks
|
||||||
|
if (node.imgs.length > 1) {
|
||||||
|
const oldImg = node.imgs[0];
|
||||||
|
if (oldImg.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(oldImg.src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// For smaller images, use data URI as before
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
node.imgs = [img];
|
||||||
|
log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${dataUrl.substring(0, 50)}...`);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
node.imgs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error("Error updating node preview:", error);
|
||||||
|
}
|
||||||
|
}, 250); // 150ms debounce delay
|
||||||
};
|
};
|
||||||
|
// Store previous temp filenames for cleanup (make it globally accessible)
|
||||||
|
if (!window.layerForgeTempFileTracker) {
|
||||||
|
window.layerForgeTempFileTracker = new Map();
|
||||||
|
}
|
||||||
|
const tempFileTracker = window.layerForgeTempFileTracker;
|
||||||
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
||||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||||
style: {
|
style: {
|
||||||
@@ -667,6 +715,10 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Inicjalizuj stan preview na podstawie aktualnej wartości widget'u
|
||||||
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
|
canvas.setPreviewVisibility(showPreviewWidget.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
@@ -794,6 +846,13 @@ app.registerExtension({
|
|||||||
const onRemoved = nodeType.prototype.onRemoved;
|
const onRemoved = nodeType.prototype.onRemoved;
|
||||||
nodeType.prototype.onRemoved = function () {
|
nodeType.prototype.onRemoved = function () {
|
||||||
log.info(`Cleaning up canvas node ${this.id}`);
|
log.info(`Cleaning up canvas node ${this.id}`);
|
||||||
|
// Clean up temp file tracker for this node (just remove from tracker)
|
||||||
|
const nodeKey = `node-${this.id}`;
|
||||||
|
const tempFileTracker = window.layerForgeTempFileTracker;
|
||||||
|
if (tempFileTracker && tempFileTracker.has(nodeKey)) {
|
||||||
|
tempFileTracker.delete(nodeKey);
|
||||||
|
log.debug(`Removed temp file tracker for node ${this.id}`);
|
||||||
|
}
|
||||||
canvasNodeInstances.delete(this.id);
|
canvasNodeInstances.delete(this.id);
|
||||||
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
||||||
if (window.canvasExecutionStates) {
|
if (window.canvasExecutionStates) {
|
||||||
@@ -814,12 +873,81 @@ app.registerExtension({
|
|||||||
};
|
};
|
||||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||||
|
// FIRST: Call original to let other extensions add their options
|
||||||
originalGetExtraMenuOptions?.apply(this, arguments);
|
originalGetExtraMenuOptions?.apply(this, arguments);
|
||||||
const self = this;
|
const self = this;
|
||||||
|
// Debug: Log all menu options AFTER other extensions have added theirs
|
||||||
|
log.info("Available menu options AFTER original call:", options.map((opt, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
content: opt?.content,
|
||||||
|
hasCallback: !!opt?.callback
|
||||||
|
})));
|
||||||
|
// Debug: Check node data to see what Impact Pack sees
|
||||||
|
const nodeData = self.constructor.nodeData || {};
|
||||||
|
log.info("Node data for Impact Pack check:", {
|
||||||
|
output: nodeData.output,
|
||||||
|
outputType: typeof nodeData.output,
|
||||||
|
isArray: Array.isArray(nodeData.output),
|
||||||
|
nodeType: self.type,
|
||||||
|
comfyClass: self.comfyClass
|
||||||
|
});
|
||||||
|
// Additional debug: Check if any option contains common Impact Pack keywords
|
||||||
|
const impactOptions = options.filter((opt, idx) => {
|
||||||
|
if (!opt || !opt.content)
|
||||||
|
return false;
|
||||||
|
const content = opt.content.toLowerCase();
|
||||||
|
return content.includes('impact') ||
|
||||||
|
content.includes('sam') ||
|
||||||
|
content.includes('detector') ||
|
||||||
|
content.includes('segment') ||
|
||||||
|
content.includes('mask') ||
|
||||||
|
content.includes('open in');
|
||||||
|
});
|
||||||
|
if (impactOptions.length > 0) {
|
||||||
|
log.info("Found potential Impact Pack options:", impactOptions.map(opt => opt.content));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("No Impact Pack-related options found in menu");
|
||||||
|
}
|
||||||
|
// Debug: Check if Impact Pack extension is loaded
|
||||||
|
const impactExtensions = app.extensions.filter((ext) => ext.name && ext.name.toLowerCase().includes('impact'));
|
||||||
|
log.info("Impact Pack extensions found:", impactExtensions.map((ext) => ext.name));
|
||||||
|
// Debug: Check menu options again after a delay to see if Impact Pack adds options later
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Menu options after 100ms delay:", options.map((opt, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
content: opt?.content,
|
||||||
|
hasCallback: !!opt?.callback
|
||||||
|
})));
|
||||||
|
// Try to find SAM Detector again
|
||||||
|
const delayedSamDetectorIndex = options.findIndex((option) => option && option.content && (option.content.includes("SAM Detector") ||
|
||||||
|
option.content.includes("SAM") ||
|
||||||
|
option.content.includes("Detector") ||
|
||||||
|
option.content.toLowerCase().includes("sam") ||
|
||||||
|
option.content.toLowerCase().includes("detector")));
|
||||||
|
if (delayedSamDetectorIndex !== -1) {
|
||||||
|
log.info(`Found SAM Detector after delay at index ${delayedSamDetectorIndex}: "${options[delayedSamDetectorIndex].content}"`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("SAM Detector still not found after delay");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
// Debug: Let's also check what the Impact Pack extension actually does
|
||||||
|
const samExtension = app.extensions.find((ext) => ext.name === 'Comfy.Impact.SAMEditor');
|
||||||
|
if (samExtension) {
|
||||||
|
log.info("SAM Extension details:", {
|
||||||
|
name: samExtension.name,
|
||||||
|
hasBeforeRegisterNodeDef: !!samExtension.beforeRegisterNodeDef,
|
||||||
|
hasInit: !!samExtension.init
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove our old MaskEditor if it exists
|
||||||
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
||||||
if (maskEditorIndex !== -1) {
|
if (maskEditorIndex !== -1) {
|
||||||
options.splice(maskEditorIndex, 1);
|
options.splice(maskEditorIndex, 1);
|
||||||
}
|
}
|
||||||
|
// Hook into "Open in SAM Detector" using the new integration module
|
||||||
|
setupSAMDetectorHook(self, options);
|
||||||
const newOptions = [
|
const newOptions = [
|
||||||
{
|
{
|
||||||
content: "Open in MaskEditor",
|
content: "Open in MaskEditor",
|
||||||
|
|||||||
@@ -258,4 +258,16 @@ export class MaskTool {
|
|||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
||||||
}
|
}
|
||||||
|
addMask(image) {
|
||||||
|
const destX = -this.x;
|
||||||
|
const destY = -this.y;
|
||||||
|
// Don't clear existing mask - just add to it
|
||||||
|
this.maskCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.maskCtx.drawImage(image, destX, destY);
|
||||||
|
if (this.onStateChange) {
|
||||||
|
this.onStateChange();
|
||||||
|
}
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
486
js/SAMDetectorIntegration.js
Normal file
486
js/SAMDetectorIntegration.js
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../scripts/app.js";
|
||||||
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
|
/**
|
||||||
|
* SAM Detector Integration for LayerForge
|
||||||
|
* Handles automatic clipspace integration and mask application from Impact Pack's SAM Detector
|
||||||
|
*/
|
||||||
|
// Function to register image in clipspace for Impact Pack compatibility
|
||||||
|
export const registerImageInClipspace = async (node, blob) => {
|
||||||
|
try {
|
||||||
|
// Upload the image to ComfyUI's temp storage for clipspace access
|
||||||
|
const formData = new FormData();
|
||||||
|
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector
|
||||||
|
formData.append("image", blob, filename);
|
||||||
|
formData.append("overwrite", "true");
|
||||||
|
formData.append("type", "temp");
|
||||||
|
const response = await api.fetchApi("/upload/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Create a proper image element with the server URL
|
||||||
|
const clipspaceImg = new Image();
|
||||||
|
clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||||
|
// Wait for image to load
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
clipspaceImg.onload = resolve;
|
||||||
|
clipspaceImg.onerror = reject;
|
||||||
|
});
|
||||||
|
log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`);
|
||||||
|
return clipspaceImg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.debug("Failed to register image in clipspace:", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge
|
||||||
|
export function startSAMDetectorMonitoring(node) {
|
||||||
|
if (node.samMonitoringActive) {
|
||||||
|
log.debug("SAM Detector monitoring already active for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.samMonitoringActive = true;
|
||||||
|
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||||
|
// Store original image source for comparison
|
||||||
|
const originalImgSrc = node.imgs?.[0]?.src;
|
||||||
|
node.samOriginalImgSrc = originalImgSrc;
|
||||||
|
// Start monitoring for SAM Detector modal closure
|
||||||
|
monitorSAMDetectorModal(node);
|
||||||
|
}
|
||||||
|
// Function to monitor SAM Detector modal closure
|
||||||
|
function monitorSAMDetectorModal(node) {
|
||||||
|
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||||
|
// Try to find modal multiple times with increasing delays
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10; // Try for 5 seconds total
|
||||||
|
const findModal = () => {
|
||||||
|
attempts++;
|
||||||
|
log.debug(`Looking for SAM Detector modal, attempt ${attempts}/${maxAttempts}`);
|
||||||
|
// Look for SAM Detector specific elements instead of generic modal
|
||||||
|
const samCanvas = document.querySelector('#samEditorMaskCanvas');
|
||||||
|
const pointsCanvas = document.querySelector('#pointsCanvas');
|
||||||
|
const imageCanvas = document.querySelector('#imageCanvas');
|
||||||
|
// Debug: Log SAM specific elements
|
||||||
|
log.debug(`SAM specific elements found:`, {
|
||||||
|
samCanvas: !!samCanvas,
|
||||||
|
pointsCanvas: !!pointsCanvas,
|
||||||
|
imageCanvas: !!imageCanvas
|
||||||
|
});
|
||||||
|
// Find the modal that contains SAM Detector elements
|
||||||
|
let modal = null;
|
||||||
|
if (samCanvas || pointsCanvas || imageCanvas) {
|
||||||
|
// Find the parent modal of SAM elements
|
||||||
|
const samElement = samCanvas || pointsCanvas || imageCanvas;
|
||||||
|
let parent = samElement?.parentElement;
|
||||||
|
while (parent && !parent.classList.contains('comfy-modal')) {
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
modal = parent;
|
||||||
|
}
|
||||||
|
if (!modal) {
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
log.debug(`SAM Detector modal not found on attempt ${attempts}, retrying in 500ms...`);
|
||||||
|
setTimeout(findModal, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.warn("SAM Detector modal not found after all attempts, falling back to polling");
|
||||||
|
// Fallback to old polling method if modal not found
|
||||||
|
monitorSAMDetectorChanges(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Found SAM Detector modal, setting up observers", {
|
||||||
|
className: modal.className,
|
||||||
|
id: modal.id,
|
||||||
|
display: window.getComputedStyle(modal).display,
|
||||||
|
children: modal.children.length,
|
||||||
|
hasSamCanvas: !!modal.querySelector('#samEditorMaskCanvas'),
|
||||||
|
hasPointsCanvas: !!modal.querySelector('#pointsCanvas'),
|
||||||
|
hasImageCanvas: !!modal.querySelector('#imageCanvas')
|
||||||
|
});
|
||||||
|
// Create a MutationObserver to watch for modal removal or style changes
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
// Check if the modal was removed from DOM
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
mutation.removedNodes.forEach((removedNode) => {
|
||||||
|
if (removedNode === modal || removedNode?.contains?.(modal)) {
|
||||||
|
log.info("SAM Detector modal removed from DOM");
|
||||||
|
handleSAMDetectorModalClosed(node);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check if modal style changed to hidden
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||||
|
const target = mutation.target;
|
||||||
|
if (target === modal) {
|
||||||
|
const display = window.getComputedStyle(modal).display;
|
||||||
|
if (display === 'none') {
|
||||||
|
log.info("SAM Detector modal hidden via style");
|
||||||
|
// Add delay to allow SAM Detector to process and save the mask
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSAMDetectorModalClosed(node);
|
||||||
|
}, 1000); // 1 second delay
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Observe the document body for child removals (modal removal)
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style']
|
||||||
|
});
|
||||||
|
// Also observe the modal itself for style changes
|
||||||
|
observer.observe(modal, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style']
|
||||||
|
});
|
||||||
|
// Store observer reference for cleanup
|
||||||
|
node.samModalObserver = observer;
|
||||||
|
// Fallback timeout in case observer doesn't catch the closure
|
||||||
|
setTimeout(() => {
|
||||||
|
if (node.samMonitoringActive) {
|
||||||
|
log.debug("SAM Detector modal monitoring timeout, cleaning up");
|
||||||
|
observer.disconnect();
|
||||||
|
node.samMonitoringActive = false;
|
||||||
|
}
|
||||||
|
}, 60000); // 1 minute timeout
|
||||||
|
log.info("SAM Detector modal observers set up successfully");
|
||||||
|
};
|
||||||
|
// Start the modal finding process
|
||||||
|
findModal();
|
||||||
|
}
|
||||||
|
// Function to handle SAM Detector modal closure
|
||||||
|
function handleSAMDetectorModalClosed(node) {
|
||||||
|
if (!node.samMonitoringActive) {
|
||||||
|
log.debug("SAM monitoring already inactive for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("SAM Detector modal closed for node", node.id);
|
||||||
|
node.samMonitoringActive = false;
|
||||||
|
// Clean up observer
|
||||||
|
if (node.samModalObserver) {
|
||||||
|
node.samModalObserver.disconnect();
|
||||||
|
delete node.samModalObserver;
|
||||||
|
}
|
||||||
|
// Check if there's a new image to process
|
||||||
|
if (node.imgs && node.imgs.length > 0) {
|
||||||
|
const currentImgSrc = node.imgs[0].src;
|
||||||
|
const originalImgSrc = node.samOriginalImgSrc;
|
||||||
|
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||||
|
log.info("SAM Detector result detected after modal closure, processing mask...");
|
||||||
|
handleSAMDetectorResult(node, node.imgs[0]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("No new image detected after SAM Detector modal closure");
|
||||||
|
// Show info notification
|
||||||
|
showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info("No image available after SAM Detector modal closure");
|
||||||
|
}
|
||||||
|
// Clean up stored references
|
||||||
|
delete node.samOriginalImgSrc;
|
||||||
|
}
|
||||||
|
// Fallback function to monitor changes in node.imgs (old polling approach)
|
||||||
|
function monitorSAMDetectorChanges(node) {
|
||||||
|
let checkCount = 0;
|
||||||
|
const maxChecks = 300; // 30 seconds maximum monitoring
|
||||||
|
const checkForChanges = () => {
|
||||||
|
checkCount++;
|
||||||
|
if (!(node.samMonitoringActive)) {
|
||||||
|
log.debug("SAM monitoring stopped for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug(`SAM monitoring check ${checkCount}/${maxChecks} for node ${node.id}`);
|
||||||
|
// Check if the node's image has been updated (this happens when "Save to node" is clicked)
|
||||||
|
if (node.imgs && node.imgs.length > 0) {
|
||||||
|
const currentImgSrc = node.imgs[0].src;
|
||||||
|
const originalImgSrc = node.samOriginalImgSrc;
|
||||||
|
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||||
|
log.info("SAM Detector result detected in node.imgs, processing mask...");
|
||||||
|
handleSAMDetectorResult(node, node.imgs[0]);
|
||||||
|
node.samMonitoringActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Continue monitoring if not exceeded max checks
|
||||||
|
if (checkCount < maxChecks && node.samMonitoringActive) {
|
||||||
|
setTimeout(checkForChanges, 100);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("SAM Detector monitoring timeout or stopped for node", node.id);
|
||||||
|
node.samMonitoringActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Start monitoring after a short delay
|
||||||
|
setTimeout(checkForChanges, 500);
|
||||||
|
}
|
||||||
|
// Function to handle SAM Detector result (using same logic as CanvasMask.handleMaskEditorClose)
|
||||||
|
async function handleSAMDetectorResult(node, resultImage) {
|
||||||
|
try {
|
||||||
|
log.info("Handling SAM Detector result for node", node.id);
|
||||||
|
log.debug("Result image source:", resultImage.src.substring(0, 100) + '...');
|
||||||
|
const canvasWidget = node.canvasWidget;
|
||||||
|
if (!canvasWidget || !canvasWidget.canvas) {
|
||||||
|
log.error("Canvas widget not available for SAM result processing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
|
||||||
|
// Wait for the result image to load (same as CanvasMask)
|
||||||
|
try {
|
||||||
|
// First check if the image is already loaded
|
||||||
|
if (resultImage.complete && resultImage.naturalWidth > 0) {
|
||||||
|
log.debug("SAM result image already loaded", {
|
||||||
|
width: resultImage.width,
|
||||||
|
height: resultImage.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Try to reload the image with a fresh request
|
||||||
|
log.debug("Attempting to reload SAM result image");
|
||||||
|
const originalSrc = resultImage.src;
|
||||||
|
// Add cache-busting parameter to force fresh load
|
||||||
|
const url = new URL(originalSrc);
|
||||||
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
// Copy the loaded image data to the original image
|
||||||
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Failed to load image from SAM Detector.", error);
|
||||||
|
showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Create temporary canvas for mask processing (same as CanvasMask)
|
||||||
|
log.debug("Creating temporary canvas for mask processing");
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = canvas.width;
|
||||||
|
tempCanvas.height = canvas.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (tempCtx) {
|
||||||
|
tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height);
|
||||||
|
log.debug("Processing image data to create mask");
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
// Convert to mask format (same as CanvasMask)
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const originalAlpha = data[i + 3];
|
||||||
|
data[i] = 255;
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = 255 - originalAlpha;
|
||||||
|
}
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
// Convert processed mask to image (same as CanvasMask)
|
||||||
|
log.debug("Converting processed mask to image");
|
||||||
|
const maskAsImage = new Image();
|
||||||
|
maskAsImage.src = tempCanvas.toDataURL();
|
||||||
|
await new Promise(resolve => maskAsImage.onload = resolve);
|
||||||
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
|
hasCanvas: !!canvas,
|
||||||
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasKeys: Object.keys(canvas)
|
||||||
|
});
|
||||||
|
if (!canvas.maskTool) {
|
||||||
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
|
hasCanvas: !!canvas,
|
||||||
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
canvasKeys: Object.keys(canvas),
|
||||||
|
maskToolValue: canvas.maskTool
|
||||||
|
});
|
||||||
|
throw new Error("Mask tool not available or not initialized");
|
||||||
|
}
|
||||||
|
log.debug("Applying SAM mask to canvas using addMask method");
|
||||||
|
// Use the addMask method which overlays on existing mask without clearing it
|
||||||
|
canvas.maskTool.addMask(maskAsImage);
|
||||||
|
// Update canvas and save state (same as CanvasMask)
|
||||||
|
canvas.render();
|
||||||
|
canvas.saveState();
|
||||||
|
// Create new preview image (same as CanvasMask)
|
||||||
|
log.debug("Creating new preview image");
|
||||||
|
const new_preview = new Image();
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
new_preview.src = URL.createObjectURL(blob);
|
||||||
|
await new Promise(r => new_preview.onload = r);
|
||||||
|
node.imgs = [new_preview];
|
||||||
|
log.debug("New preview image created successfully");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.warn("Failed to create preview blob");
|
||||||
|
}
|
||||||
|
canvas.render();
|
||||||
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
|
// Show success notification
|
||||||
|
showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error("Error processing SAM Detector result:", error);
|
||||||
|
// Show error notification
|
||||||
|
showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
node.samMonitoringActive = false;
|
||||||
|
node.samOriginalImgSrc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Helper function to show notifications
|
||||||
|
function showNotification(message, backgroundColor, duration) {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: ${backgroundColor};
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10001;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
notification.textContent = message;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
// Function to setup SAM Detector hook in menu options
|
||||||
|
export function setupSAMDetectorHook(node, options) {
|
||||||
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
|
const hookSAMDetector = () => {
|
||||||
|
const samDetectorIndex = options.findIndex((option) => option && option.content && (option.content.includes("SAM Detector") ||
|
||||||
|
option.content === "Open in SAM Detector"));
|
||||||
|
if (samDetectorIndex !== -1) {
|
||||||
|
log.info(`Found SAM Detector menu item at index ${samDetectorIndex}: "${options[samDetectorIndex].content}"`);
|
||||||
|
const originalSamCallback = options[samDetectorIndex].callback;
|
||||||
|
options[samDetectorIndex].callback = async () => {
|
||||||
|
try {
|
||||||
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
|
if (node.canvasWidget && node.canvasWidget.canvas) {
|
||||||
|
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
||||||
|
// Get the flattened canvas as blob
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error("Failed to generate canvas blob");
|
||||||
|
}
|
||||||
|
// Upload the image to ComfyUI's temp storage
|
||||||
|
const formData = new FormData();
|
||||||
|
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp
|
||||||
|
formData.append("image", blob, filename);
|
||||||
|
formData.append("overwrite", "true");
|
||||||
|
formData.append("type", "temp");
|
||||||
|
const response = await api.fetchApi("/upload/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
log.debug('Image uploaded for SAM Detector:', data);
|
||||||
|
// Create image element with proper URL
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous"; // Add CORS support
|
||||||
|
// Wait for image to load before setting src
|
||||||
|
const imageLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
log.debug("SAM Detector image loaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
src: img.src.substring(0, 100) + '...'
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load SAM Detector image", error);
|
||||||
|
reject(new Error("Failed to load uploaded image"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Set src after setting up event handlers
|
||||||
|
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||||
|
// Wait for image to load
|
||||||
|
await imageLoadPromise;
|
||||||
|
// Set the image to the node for clipspace
|
||||||
|
node.imgs = [img];
|
||||||
|
node.clipspaceImg = img;
|
||||||
|
// Copy to ComfyUI clipspace
|
||||||
|
ComfyApp.copyToClipspace(node);
|
||||||
|
// Start monitoring for SAM Detector results
|
||||||
|
startSAMDetectorMonitoring(node);
|
||||||
|
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||||
|
}
|
||||||
|
// Call the original SAM Detector callback
|
||||||
|
if (originalSamCallback) {
|
||||||
|
await originalSamCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
log.error("Error in SAM Detector hook:", e);
|
||||||
|
// Still try to call original callback
|
||||||
|
if (originalSamCallback) {
|
||||||
|
await originalSamCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return true; // Found and hooked
|
||||||
|
}
|
||||||
|
return false; // Not found
|
||||||
|
};
|
||||||
|
// Try to hook immediately
|
||||||
|
if (!hookSAMDetector()) {
|
||||||
|
// If not found immediately, try again after Impact Pack adds it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (hookSAMDetector()) {
|
||||||
|
log.info("Successfully hooked SAM Detector after delay");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.debug("SAM Detector menu item not found even after delay");
|
||||||
|
}
|
||||||
|
}, 150); // Slightly longer delay to ensure Impact Pack has added it
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,9 @@ export function getStateSignature(layers) {
|
|||||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||||
zIndex: layer.zIndex,
|
zIndex: layer.zIndex,
|
||||||
blendMode: layer.blendMode || 'normal',
|
blendMode: layer.blendMode || 'normal',
|
||||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
|
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
|
||||||
|
flipH: !!layer.flipH,
|
||||||
|
flipV: !!layer.flipV
|
||||||
};
|
};
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
sig.imageId = layer.imageId;
|
sig.imageId = layer.imageId;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.3.7"
|
version = "1.3.9"
|
||||||
license = {file = "LICENSE"}
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://github.com/Azornes/Comfyui-LayerForge"
|
Repository = "https://github.com/Azornes/Comfyui-LayerForge"
|
||||||
# Used by Comfy Registry https://registry.comfy.org
|
|
||||||
|
|
||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
PublisherId = "azornes"
|
PublisherId = "azornes"
|
||||||
DisplayName = "Comfyui-LayerForge"
|
DisplayName = "Comfyui-LayerForge"
|
||||||
Icon = ""
|
Icon = ""
|
||||||
includes = []
|
|
||||||
@@ -98,7 +98,8 @@ export class Canvas {
|
|||||||
|
|
||||||
this.offscreenCanvas = document.createElement('canvas');
|
this.offscreenCanvas = document.createElement('canvas');
|
||||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||||
alpha: false
|
alpha: false,
|
||||||
|
willReadFrequently: true
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
@@ -132,7 +133,7 @@ export class Canvas {
|
|||||||
viewport: this.viewport
|
viewport: this.viewport
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setPreviewVisibility(false);
|
this.previewVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@ export class Canvas {
|
|||||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||||
|
|
||||||
this._addAutoRefreshToggle();
|
this._setupAutoRefreshHandlers();
|
||||||
|
|
||||||
log.debug('Canvas modules initialized successfully');
|
log.debug('Canvas modules initialized successfully');
|
||||||
}
|
}
|
||||||
@@ -411,12 +412,17 @@ export class Canvas {
|
|||||||
return this.canvasIO.importLatestImage();
|
return this.canvasIO.importLatestImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
_addAutoRefreshToggle() {
|
_setupAutoRefreshHandlers() {
|
||||||
let autoRefreshEnabled = false;
|
|
||||||
let lastExecutionStartTime = 0;
|
let lastExecutionStartTime = 0;
|
||||||
|
|
||||||
|
// Helper function to get auto-refresh value from node widget
|
||||||
|
const getAutoRefreshValue = (): boolean => {
|
||||||
|
const widget = this.node.widgets.find((w: any) => w.name === 'auto_refresh_after_generation');
|
||||||
|
return widget ? widget.value : false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleExecutionStart = () => {
|
const handleExecutionStart = () => {
|
||||||
if (autoRefreshEnabled) {
|
if (getAutoRefreshValue()) {
|
||||||
lastExecutionStartTime = Date.now();
|
lastExecutionStartTime = Date.now();
|
||||||
// Store a snapshot of the context for the upcoming batch
|
// Store a snapshot of the context for the upcoming batch
|
||||||
this.pendingBatchContext = {
|
this.pendingBatchContext = {
|
||||||
@@ -439,7 +445,7 @@ export class Canvas {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExecutionSuccess = async () => {
|
const handleExecutionSuccess = async () => {
|
||||||
if (autoRefreshEnabled) {
|
if (getAutoRefreshValue()) {
|
||||||
log.info('Auto-refresh triggered, importing latest images.');
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
|
|
||||||
if (!this.pendingBatchContext) {
|
if (!this.pendingBatchContext) {
|
||||||
@@ -470,18 +476,6 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.node.addWidget(
|
|
||||||
'toggle',
|
|
||||||
'Auto-refresh after generation',
|
|
||||||
false,
|
|
||||||
(value: boolean) => {
|
|
||||||
autoRefreshEnabled = value;
|
|
||||||
log.debug('Auto-refresh toggled:', value);
|
|
||||||
}, {
|
|
||||||
serialize: false
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
api.addEventListener('execution_start', handleExecutionStart);
|
api.addEventListener('execution_start', handleExecutionStart);
|
||||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||||
|
|
||||||
@@ -490,6 +484,8 @@ export class Canvas {
|
|||||||
api.removeEventListener('execution_start', handleExecutionStart);
|
api.removeEventListener('execution_start', handleExecutionStart);
|
||||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -72,27 +72,11 @@ export class CanvasIO {
|
|||||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||||
const sortedLayers = this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
|
||||||
log.debug(`Processing ${sortedLayers.length} layers in order`);
|
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||||
log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`);
|
|
||||||
log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
|
log.debug(`Finished rendering layers`);
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
|
||||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
tempCtx.restore();
|
|
||||||
|
|
||||||
log.debug(`Layer ${index} rendered successfully`);
|
|
||||||
visibilityCtx.save();
|
|
||||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
visibilityCtx.restore();
|
|
||||||
});
|
|
||||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||||
@@ -259,23 +243,8 @@ export class CanvasIO {
|
|||||||
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
||||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
const sortedLayers = this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||||
sortedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
|
||||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
tempCtx.restore();
|
|
||||||
|
|
||||||
visibilityCtx.save();
|
|
||||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
||||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
visibilityCtx.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
||||||
|
|
||||||
|
// Add a blur event listener to the window to reset key states
|
||||||
|
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||||
|
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||||
@@ -426,6 +429,26 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleBlur(): void {
|
||||||
|
log.debug('Window lost focus, resetting key states.');
|
||||||
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isAltPressed = false;
|
||||||
|
this.interaction.keyMovementInProgress = false;
|
||||||
|
|
||||||
|
// Also reset any interaction that relies on a key being held down
|
||||||
|
if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) {
|
||||||
|
// If we were in the middle of a cloning drag, finalize it
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.canvasState.saveStateToDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset interaction mode if it's something that can get "stuck"
|
||||||
|
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
|
||||||
|
this.resetInteractionState();
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateCursor(worldCoords: Point): void {
|
updateCursor(worldCoords: Point): void {
|
||||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
|
|||||||
@@ -349,60 +349,60 @@ export class CanvasLayers {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||||
|
if (!layer.image) return;
|
||||||
|
|
||||||
|
const { offsetX = 0, offsetY = 0 } = options;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||||
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
|
|
||||||
|
const centerX = layer.x + layer.width / 2 - offsetX;
|
||||||
|
const centerY = layer.y + layer.height / 2 - offsetY;
|
||||||
|
|
||||||
|
ctx.translate(centerX, centerY);
|
||||||
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
|
||||||
|
const scaleH = layer.flipH ? -1 : 1;
|
||||||
|
const scaleV = layer.flipV ? -1 : 1;
|
||||||
|
if (layer.flipH || layer.flipV) {
|
||||||
|
ctx.scale(scaleH, scaleV);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
-layer.width / 2, -layer.height / 2,
|
||||||
|
layer.width, layer.height
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||||
|
const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
||||||
|
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
public drawLayersToContext(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||||
|
this._drawLayers(ctx, layers, options);
|
||||||
|
}
|
||||||
|
|
||||||
async mirrorHorizontal(): Promise<void> {
|
async mirrorHorizontal(): Promise<void> {
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||||
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
const promises = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => {
|
layer.flipH = !layer.flipH;
|
||||||
return new Promise<void>(resolve => {
|
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
|
||||||
if (!tempCtx) return;
|
|
||||||
tempCanvas.width = layer.image.width;
|
|
||||||
tempCanvas.height = layer.image.height;
|
|
||||||
|
|
||||||
tempCtx.translate(tempCanvas.width, 0);
|
|
||||||
tempCtx.scale(-1, 1);
|
|
||||||
tempCtx.drawImage(layer.image, 0, 0);
|
|
||||||
|
|
||||||
const newImage = new Image();
|
|
||||||
newImage.onload = () => {
|
|
||||||
layer.image = newImage;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
newImage.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async mirrorVertical(): Promise<void> {
|
async mirrorVertical(): Promise<void> {
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||||
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
const promises = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => {
|
layer.flipV = !layer.flipV;
|
||||||
return new Promise<void>(resolve => {
|
|
||||||
const tempCanvas = document.createElement('canvas');
|
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
|
||||||
if (!tempCtx) return;
|
|
||||||
tempCanvas.width = layer.image.width;
|
|
||||||
tempCanvas.height = layer.image.height;
|
|
||||||
|
|
||||||
tempCtx.translate(0, tempCanvas.height);
|
|
||||||
tempCtx.scale(1, -1);
|
|
||||||
tempCtx.drawImage(layer.image, 0, 0);
|
|
||||||
|
|
||||||
const newImage = new Image();
|
|
||||||
newImage.onload = () => {
|
|
||||||
layer.image = newImage;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
newImage.src = tempCanvas.toDataURL();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
@@ -410,25 +410,21 @@ export class CanvasLayers {
|
|||||||
async getLayerImageData(layer: Layer): Promise<string> {
|
async getLayerImageData(layer: Layer): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) throw new Error("Could not create canvas context");
|
if (!tempCtx) throw new Error("Could not create canvas context");
|
||||||
|
|
||||||
tempCanvas.width = layer.width;
|
tempCanvas.width = layer.width;
|
||||||
tempCanvas.height = layer.height;
|
tempCanvas.height = layer.height;
|
||||||
|
|
||||||
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
|
||||||
|
// by creating a temporary layer object for drawing.
|
||||||
|
const layerToDraw = {
|
||||||
|
...layer,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
|
||||||
tempCtx.save();
|
this._drawLayer(tempCtx, layerToDraw);
|
||||||
tempCtx.translate(layer.width / 2, layer.height / 2);
|
|
||||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
||||||
tempCtx.drawImage(
|
|
||||||
layer.image,
|
|
||||||
-layer.width / 2,
|
|
||||||
-layer.height / 2,
|
|
||||||
layer.width,
|
|
||||||
layer.height
|
|
||||||
);
|
|
||||||
tempCtx.restore();
|
|
||||||
|
|
||||||
const dataUrl = tempCanvas.toDataURL('image/png');
|
const dataUrl = tempCanvas.toDataURL('image/png');
|
||||||
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
||||||
@@ -691,33 +687,13 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.canvas.width;
|
tempCanvas.width = this.canvas.width;
|
||||||
tempCanvas.height = this.canvas.height;
|
tempCanvas.height = this.canvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.layers);
|
||||||
|
|
||||||
sortedLayers.forEach((layer: Layer) => {
|
|
||||||
if (!layer.image) return;
|
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || '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 imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
@@ -727,7 +703,7 @@ export class CanvasLayers {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempMaskCtx) {
|
if (!tempMaskCtx) {
|
||||||
reject(new Error("Could not create mask canvas context"));
|
reject(new Error("Could not create mask canvas context"));
|
||||||
return;
|
return;
|
||||||
@@ -788,33 +764,13 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.canvas.width;
|
tempCanvas.width = this.canvas.width;
|
||||||
tempCanvas.height = this.canvas.height;
|
tempCanvas.height = this.canvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.layers);
|
||||||
|
|
||||||
sortedLayers.forEach((layer: Layer) => {
|
|
||||||
if (!layer.image) return;
|
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || '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();
|
|
||||||
});
|
|
||||||
|
|
||||||
tempCanvas.toBlob((blob) => {
|
tempCanvas.toBlob((blob) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
@@ -875,7 +831,7 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = newWidth;
|
tempCanvas.width = newWidth;
|
||||||
tempCanvas.height = newHeight;
|
tempCanvas.height = newHeight;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) {
|
if (!tempCtx) {
|
||||||
reject(new Error("Could not create canvas context"));
|
reject(new Error("Could not create canvas context"));
|
||||||
return;
|
return;
|
||||||
@@ -883,26 +839,7 @@ export class CanvasLayers {
|
|||||||
|
|
||||||
tempCtx.translate(-minX, -minY);
|
tempCtx.translate(-minX, -minY);
|
||||||
|
|
||||||
const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||||
|
|
||||||
sortedSelection.forEach((layer: Layer) => {
|
|
||||||
if (!layer.image) return;
|
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || '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();
|
|
||||||
});
|
|
||||||
|
|
||||||
tempCanvas.toBlob((blob) => {
|
tempCanvas.toBlob((blob) => {
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
@@ -961,31 +898,12 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = fusedWidth;
|
tempCanvas.width = fusedWidth;
|
||||||
tempCanvas.height = fusedHeight;
|
tempCanvas.height = fusedHeight;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
if (!tempCtx) throw new Error("Could not create canvas context");
|
if (!tempCtx) throw new Error("Could not create canvas context");
|
||||||
|
|
||||||
tempCtx.translate(-minX, -minY);
|
tempCtx.translate(-minX, -minY);
|
||||||
|
|
||||||
const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||||
|
|
||||||
sortedSelection.forEach((layer: Layer) => {
|
|
||||||
if (!layer.image) return;
|
|
||||||
|
|
||||||
tempCtx.save();
|
|
||||||
tempCtx.globalCompositeOperation = layer.blendMode as any || '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 fusedImage = new Image();
|
const fusedImage = new Image();
|
||||||
fusedImage.src = tempCanvas.toDataURL();
|
fusedImage.src = tempCanvas.toDataURL();
|
||||||
@@ -1032,7 +950,7 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info("Layers fused successfully", {
|
log.info("Layers fused successfully", {
|
||||||
originalLayerCount: sortedSelection.length,
|
originalLayerCount: this.canvas.canvasSelection.selectedLayers.length,
|
||||||
fusedDimensions: { width: fusedWidth, height: fusedHeight },
|
fusedDimensions: { width: fusedWidth, height: fusedHeight },
|
||||||
fusedPosition: { x: minX, y: minY }
|
fusedPosition: { x: minX, y: minY }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export class CanvasRenderer {
|
|||||||
const centerY = layer.y + layer.height / 2;
|
const centerY = layer.y + layer.height / 2;
|
||||||
ctx.translate(centerX, centerY);
|
ctx.translate(centerX, centerY);
|
||||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
|
||||||
|
const scaleH = layer.flipH ? -1 : 1;
|
||||||
|
const scaleV = layer.flipV ? -1 : 1;
|
||||||
|
if (layer.flipH || layer.flipV) {
|
||||||
|
ctx.scale(scaleH, scaleV);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
|
|||||||
@@ -297,13 +297,14 @@ export class CanvasState {
|
|||||||
delete (newLayer as any).image;
|
delete (newLayer as any).image;
|
||||||
|
|
||||||
if (layer.image instanceof HTMLImageElement) {
|
if (layer.image instanceof HTMLImageElement) {
|
||||||
log.debug(`Layer ${index}: Using imageId instead of serializing image.`);
|
if (layer.imageId) {
|
||||||
if (!layer.imageId) {
|
newLayer.imageId = layer.imageId;
|
||||||
|
} else {
|
||||||
|
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||||
newLayer.imageId = generateUUID();
|
newLayer.imageId = generateUUID();
|
||||||
const imageBitmap = await createImageBitmap(layer.image);
|
const imageBitmap = await createImageBitmap(layer.image);
|
||||||
await saveImage(newLayer.imageId, imageBitmap);
|
await saveImage(newLayer.imageId, imageBitmap);
|
||||||
}
|
}
|
||||||
newLayer.imageId = layer.imageId;
|
|
||||||
} else if (!layer.imageId) {
|
} else if (!layer.imageId) {
|
||||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {clearAllCanvasStates} from "./db.js";
|
|||||||
import {ImageCache} from "./ImageCache.js";
|
import {ImageCache} from "./ImageCache.js";
|
||||||
import {generateUniqueFileName} from "./utils/CommonUtils.js";
|
import {generateUniqueFileName} from "./utils/CommonUtils.js";
|
||||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||||
|
import { registerImageInClipspace, startSAMDetectorMonitoring, setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||||
import type { ComfyNode, Layer, AddMode } from './types';
|
import type { ComfyNode, Layer, AddMode } from './types';
|
||||||
|
|
||||||
const log = createModuleLogger('Canvas_view');
|
const log = createModuleLogger('Canvas_view');
|
||||||
@@ -363,7 +364,7 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
const mattedImage = new Image();
|
const mattedImage = new Image();
|
||||||
mattedImage.src = result.matted_image;
|
mattedImage.src = result.matted_image;
|
||||||
await mattedImage.decode();
|
await mattedImage.decode();
|
||||||
const newLayer = {...selectedLayer, image: mattedImage} as Layer;
|
const newLayer = {...selectedLayer, image: mattedImage, flipH: false, flipV: false} as Layer;
|
||||||
delete (newLayer as any).imageId;
|
delete (newLayer as any).imageId;
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.canvasSelection.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
@@ -568,27 +569,77 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
canvas.updateHistoryButtons();
|
canvas.updateHistoryButtons();
|
||||||
|
|
||||||
|
// Debounce timer for updateOutput to prevent excessive updates
|
||||||
|
let updateOutputTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
const updateOutput = async (node: ComfyNode, canvas: Canvas) => {
|
const updateOutput = async (node: ComfyNode, canvas: Canvas) => {
|
||||||
|
// Check if preview is disabled - if so, skip updateOutput entirely
|
||||||
|
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
|
||||||
|
if (showPreviewWidget && !showPreviewWidget.value) {
|
||||||
|
log.debug("Preview disabled, skipping updateOutput");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
||||||
if (triggerWidget) {
|
if (triggerWidget) {
|
||||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Clear previous timer
|
||||||
const new_preview = new Image();
|
if (updateOutputTimer) {
|
||||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
clearTimeout(updateOutputTimer);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce the update to prevent excessive processing during rapid changes
|
||||||
|
updateOutputTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
// For large images, use blob URL for better performance
|
||||||
|
if (blob.size > 2 * 1024 * 1024) { // 2MB threshold
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
node.imgs = [img];
|
||||||
|
log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`);
|
||||||
|
// Clean up old blob URLs to prevent memory leaks
|
||||||
|
if (node.imgs.length > 1) {
|
||||||
|
const oldImg = node.imgs[0];
|
||||||
|
if (oldImg.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(oldImg.src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = blobUrl;
|
||||||
|
} else {
|
||||||
|
// For smaller images, use data URI as before
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
node.imgs = [img];
|
||||||
|
log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${dataUrl.substring(0, 50)}...`);
|
||||||
|
};
|
||||||
|
img.src = dataUrl;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.imgs = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating node preview:", error);
|
||||||
|
}
|
||||||
|
}, 250); // 150ms debounce delay
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store previous temp filenames for cleanup (make it globally accessible)
|
||||||
|
if (!(window as any).layerForgeTempFileTracker) {
|
||||||
|
(window as any).layerForgeTempFileTracker = new Map<string, string>();
|
||||||
|
}
|
||||||
|
const tempFileTracker = (window as any).layerForgeTempFileTracker;
|
||||||
|
|
||||||
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
||||||
|
|
||||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||||
@@ -730,6 +781,11 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
node.setDirtyCanvas(true, true);
|
node.setDirtyCanvas(true, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inicjalizuj stan preview na podstawie aktualnej wartości widget'u
|
||||||
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
|
canvas.setPreviewVisibility(showPreviewWidget.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -874,6 +930,14 @@ app.registerExtension({
|
|||||||
nodeType.prototype.onRemoved = function (this: ComfyNode) {
|
nodeType.prototype.onRemoved = function (this: ComfyNode) {
|
||||||
log.info(`Cleaning up canvas node ${this.id}`);
|
log.info(`Cleaning up canvas node ${this.id}`);
|
||||||
|
|
||||||
|
// Clean up temp file tracker for this node (just remove from tracker)
|
||||||
|
const nodeKey = `node-${this.id}`;
|
||||||
|
const tempFileTracker = (window as any).layerForgeTempFileTracker;
|
||||||
|
if (tempFileTracker && tempFileTracker.has(nodeKey)) {
|
||||||
|
tempFileTracker.delete(nodeKey);
|
||||||
|
log.debug(`Removed temp file tracker for node ${this.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
canvasNodeInstances.delete(this.id);
|
canvasNodeInstances.delete(this.id);
|
||||||
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
||||||
|
|
||||||
@@ -899,15 +963,97 @@ app.registerExtension({
|
|||||||
|
|
||||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||||
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
||||||
|
// FIRST: Call original to let other extensions add their options
|
||||||
originalGetExtraMenuOptions?.apply(this, arguments as any);
|
originalGetExtraMenuOptions?.apply(this, arguments as any);
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
|
// Debug: Log all menu options AFTER other extensions have added theirs
|
||||||
|
log.info("Available menu options AFTER original call:", options.map((opt, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
content: opt?.content,
|
||||||
|
hasCallback: !!opt?.callback
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Debug: Check node data to see what Impact Pack sees
|
||||||
|
const nodeData = (self as any).constructor.nodeData || {};
|
||||||
|
log.info("Node data for Impact Pack check:", {
|
||||||
|
output: nodeData.output,
|
||||||
|
outputType: typeof nodeData.output,
|
||||||
|
isArray: Array.isArray(nodeData.output),
|
||||||
|
nodeType: (self as any).type,
|
||||||
|
comfyClass: (self as any).comfyClass
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional debug: Check if any option contains common Impact Pack keywords
|
||||||
|
const impactOptions = options.filter((opt, idx) => {
|
||||||
|
if (!opt || !opt.content) return false;
|
||||||
|
const content = opt.content.toLowerCase();
|
||||||
|
return content.includes('impact') ||
|
||||||
|
content.includes('sam') ||
|
||||||
|
content.includes('detector') ||
|
||||||
|
content.includes('segment') ||
|
||||||
|
content.includes('mask') ||
|
||||||
|
content.includes('open in');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (impactOptions.length > 0) {
|
||||||
|
log.info("Found potential Impact Pack options:", impactOptions.map(opt => opt.content));
|
||||||
|
} else {
|
||||||
|
log.info("No Impact Pack-related options found in menu");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Check if Impact Pack extension is loaded
|
||||||
|
const impactExtensions = app.extensions.filter((ext: any) =>
|
||||||
|
ext.name && ext.name.toLowerCase().includes('impact')
|
||||||
|
);
|
||||||
|
log.info("Impact Pack extensions found:", impactExtensions.map((ext: any) => ext.name));
|
||||||
|
|
||||||
|
// Debug: Check menu options again after a delay to see if Impact Pack adds options later
|
||||||
|
setTimeout(() => {
|
||||||
|
log.info("Menu options after 100ms delay:", options.map((opt, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
content: opt?.content,
|
||||||
|
hasCallback: !!opt?.callback
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Try to find SAM Detector again
|
||||||
|
const delayedSamDetectorIndex = options.findIndex((option) =>
|
||||||
|
option && option.content && (
|
||||||
|
option.content.includes("SAM Detector") ||
|
||||||
|
option.content.includes("SAM") ||
|
||||||
|
option.content.includes("Detector") ||
|
||||||
|
option.content.toLowerCase().includes("sam") ||
|
||||||
|
option.content.toLowerCase().includes("detector")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (delayedSamDetectorIndex !== -1) {
|
||||||
|
log.info(`Found SAM Detector after delay at index ${delayedSamDetectorIndex}: "${options[delayedSamDetectorIndex].content}"`);
|
||||||
|
} else {
|
||||||
|
log.info("SAM Detector still not found after delay");
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Debug: Let's also check what the Impact Pack extension actually does
|
||||||
|
const samExtension = app.extensions.find((ext: any) => ext.name === 'Comfy.Impact.SAMEditor');
|
||||||
|
if (samExtension) {
|
||||||
|
log.info("SAM Extension details:", {
|
||||||
|
name: samExtension.name,
|
||||||
|
hasBeforeRegisterNodeDef: !!samExtension.beforeRegisterNodeDef,
|
||||||
|
hasInit: !!samExtension.init
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove our old MaskEditor if it exists
|
||||||
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
||||||
if (maskEditorIndex !== -1) {
|
if (maskEditorIndex !== -1) {
|
||||||
options.splice(maskEditorIndex, 1);
|
options.splice(maskEditorIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook into "Open in SAM Detector" using the new integration module
|
||||||
|
setupSAMDetectorHook(self, options);
|
||||||
|
|
||||||
const newOptions = [
|
const newOptions = [
|
||||||
{
|
{
|
||||||
content: "Open in MaskEditor",
|
content: "Open in MaskEditor",
|
||||||
|
|||||||
@@ -336,4 +336,19 @@ export class MaskTool {
|
|||||||
this.canvasInstance.render();
|
this.canvasInstance.render();
|
||||||
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addMask(image: HTMLImageElement): void {
|
||||||
|
const destX = -this.x;
|
||||||
|
const destY = -this.y;
|
||||||
|
|
||||||
|
// Don't clear existing mask - just add to it
|
||||||
|
this.maskCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.maskCtx.drawImage(image, destX, destY);
|
||||||
|
|
||||||
|
if (this.onStateChange) {
|
||||||
|
this.onStateChange();
|
||||||
|
}
|
||||||
|
this.canvasInstance.render();
|
||||||
|
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
566
src/SAMDetectorIntegration.ts
Normal file
566
src/SAMDetectorIntegration.ts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ComfyApp } from "../../scripts/app.js";
|
||||||
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
|
import type { ComfyNode } from './types';
|
||||||
|
|
||||||
|
const log = createModuleLogger('SAMDetectorIntegration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAM Detector Integration for LayerForge
|
||||||
|
* Handles automatic clipspace integration and mask application from Impact Pack's SAM Detector
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Function to register image in clipspace for Impact Pack compatibility
|
||||||
|
export const registerImageInClipspace = async (node: ComfyNode, blob: Blob): Promise<HTMLImageElement | null> => {
|
||||||
|
try {
|
||||||
|
// Upload the image to ComfyUI's temp storage for clipspace access
|
||||||
|
const formData = new FormData();
|
||||||
|
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector
|
||||||
|
formData.append("image", blob, filename);
|
||||||
|
formData.append("overwrite", "true");
|
||||||
|
formData.append("type", "temp");
|
||||||
|
|
||||||
|
const response = await api.fetchApi("/upload/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Create a proper image element with the server URL
|
||||||
|
const clipspaceImg = new Image();
|
||||||
|
clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||||
|
|
||||||
|
// Wait for image to load
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
clipspaceImg.onload = resolve;
|
||||||
|
clipspaceImg.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`);
|
||||||
|
return clipspaceImg;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Failed to register image in clipspace:", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge
|
||||||
|
export function startSAMDetectorMonitoring(node: ComfyNode) {
|
||||||
|
if ((node as any).samMonitoringActive) {
|
||||||
|
log.debug("SAM Detector monitoring already active for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(node as any).samMonitoringActive = true;
|
||||||
|
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||||
|
|
||||||
|
// Store original image source for comparison
|
||||||
|
const originalImgSrc = node.imgs?.[0]?.src;
|
||||||
|
(node as any).samOriginalImgSrc = originalImgSrc;
|
||||||
|
|
||||||
|
// Start monitoring for SAM Detector modal closure
|
||||||
|
monitorSAMDetectorModal(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to monitor SAM Detector modal closure
|
||||||
|
function monitorSAMDetectorModal(node: ComfyNode) {
|
||||||
|
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||||
|
|
||||||
|
// Try to find modal multiple times with increasing delays
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10; // Try for 5 seconds total
|
||||||
|
|
||||||
|
const findModal = () => {
|
||||||
|
attempts++;
|
||||||
|
log.debug(`Looking for SAM Detector modal, attempt ${attempts}/${maxAttempts}`);
|
||||||
|
|
||||||
|
// Look for SAM Detector specific elements instead of generic modal
|
||||||
|
const samCanvas = document.querySelector('#samEditorMaskCanvas') as HTMLElement;
|
||||||
|
const pointsCanvas = document.querySelector('#pointsCanvas') as HTMLElement;
|
||||||
|
const imageCanvas = document.querySelector('#imageCanvas') as HTMLElement;
|
||||||
|
|
||||||
|
// Debug: Log SAM specific elements
|
||||||
|
log.debug(`SAM specific elements found:`, {
|
||||||
|
samCanvas: !!samCanvas,
|
||||||
|
pointsCanvas: !!pointsCanvas,
|
||||||
|
imageCanvas: !!imageCanvas
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the modal that contains SAM Detector elements
|
||||||
|
let modal: HTMLElement | null = null;
|
||||||
|
if (samCanvas || pointsCanvas || imageCanvas) {
|
||||||
|
// Find the parent modal of SAM elements
|
||||||
|
const samElement = samCanvas || pointsCanvas || imageCanvas;
|
||||||
|
let parent = samElement?.parentElement;
|
||||||
|
while (parent && !parent.classList.contains('comfy-modal')) {
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
modal = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modal) {
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
log.debug(`SAM Detector modal not found on attempt ${attempts}, retrying in 500ms...`);
|
||||||
|
setTimeout(findModal, 500);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
log.warn("SAM Detector modal not found after all attempts, falling back to polling");
|
||||||
|
// Fallback to old polling method if modal not found
|
||||||
|
monitorSAMDetectorChanges(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Found SAM Detector modal, setting up observers", {
|
||||||
|
className: modal.className,
|
||||||
|
id: modal.id,
|
||||||
|
display: window.getComputedStyle(modal).display,
|
||||||
|
children: modal.children.length,
|
||||||
|
hasSamCanvas: !!modal.querySelector('#samEditorMaskCanvas'),
|
||||||
|
hasPointsCanvas: !!modal.querySelector('#pointsCanvas'),
|
||||||
|
hasImageCanvas: !!modal.querySelector('#imageCanvas')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a MutationObserver to watch for modal removal or style changes
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
// Check if the modal was removed from DOM
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
mutation.removedNodes.forEach((removedNode) => {
|
||||||
|
if (removedNode === modal || (removedNode as Element)?.contains?.(modal)) {
|
||||||
|
log.info("SAM Detector modal removed from DOM");
|
||||||
|
handleSAMDetectorModalClosed(node);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if modal style changed to hidden
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||||
|
const target = mutation.target as HTMLElement;
|
||||||
|
if (target === modal) {
|
||||||
|
const display = window.getComputedStyle(modal).display;
|
||||||
|
if (display === 'none') {
|
||||||
|
log.info("SAM Detector modal hidden via style");
|
||||||
|
// Add delay to allow SAM Detector to process and save the mask
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSAMDetectorModalClosed(node);
|
||||||
|
}, 1000); // 1 second delay
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe the document body for child removals (modal removal)
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also observe the modal itself for style changes
|
||||||
|
observer.observe(modal, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store observer reference for cleanup
|
||||||
|
(node as any).samModalObserver = observer;
|
||||||
|
|
||||||
|
// Fallback timeout in case observer doesn't catch the closure
|
||||||
|
setTimeout(() => {
|
||||||
|
if ((node as any).samMonitoringActive) {
|
||||||
|
log.debug("SAM Detector modal monitoring timeout, cleaning up");
|
||||||
|
observer.disconnect();
|
||||||
|
(node as any).samMonitoringActive = false;
|
||||||
|
}
|
||||||
|
}, 60000); // 1 minute timeout
|
||||||
|
|
||||||
|
log.info("SAM Detector modal observers set up successfully");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the modal finding process
|
||||||
|
findModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle SAM Detector modal closure
|
||||||
|
function handleSAMDetectorModalClosed(node: ComfyNode) {
|
||||||
|
if (!(node as any).samMonitoringActive) {
|
||||||
|
log.debug("SAM monitoring already inactive for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("SAM Detector modal closed for node", node.id);
|
||||||
|
(node as any).samMonitoringActive = false;
|
||||||
|
|
||||||
|
// Clean up observer
|
||||||
|
if ((node as any).samModalObserver) {
|
||||||
|
(node as any).samModalObserver.disconnect();
|
||||||
|
delete (node as any).samModalObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a new image to process
|
||||||
|
if (node.imgs && node.imgs.length > 0) {
|
||||||
|
const currentImgSrc = node.imgs[0].src;
|
||||||
|
const originalImgSrc = (node as any).samOriginalImgSrc;
|
||||||
|
|
||||||
|
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||||
|
log.info("SAM Detector result detected after modal closure, processing mask...");
|
||||||
|
handleSAMDetectorResult(node, node.imgs[0]);
|
||||||
|
} else {
|
||||||
|
log.info("No new image detected after SAM Detector modal closure");
|
||||||
|
|
||||||
|
// Show info notification
|
||||||
|
showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("No image available after SAM Detector modal closure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stored references
|
||||||
|
delete (node as any).samOriginalImgSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback function to monitor changes in node.imgs (old polling approach)
|
||||||
|
function monitorSAMDetectorChanges(node: ComfyNode) {
|
||||||
|
let checkCount = 0;
|
||||||
|
const maxChecks = 300; // 30 seconds maximum monitoring
|
||||||
|
|
||||||
|
const checkForChanges = () => {
|
||||||
|
checkCount++;
|
||||||
|
|
||||||
|
if (!((node as any).samMonitoringActive)) {
|
||||||
|
log.debug("SAM monitoring stopped for node", node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`SAM monitoring check ${checkCount}/${maxChecks} for node ${node.id}`);
|
||||||
|
|
||||||
|
// Check if the node's image has been updated (this happens when "Save to node" is clicked)
|
||||||
|
if (node.imgs && node.imgs.length > 0) {
|
||||||
|
const currentImgSrc = node.imgs[0].src;
|
||||||
|
const originalImgSrc = (node as any).samOriginalImgSrc;
|
||||||
|
|
||||||
|
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||||
|
log.info("SAM Detector result detected in node.imgs, processing mask...");
|
||||||
|
handleSAMDetectorResult(node, node.imgs[0]);
|
||||||
|
(node as any).samMonitoringActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue monitoring if not exceeded max checks
|
||||||
|
if (checkCount < maxChecks && (node as any).samMonitoringActive) {
|
||||||
|
setTimeout(checkForChanges, 100);
|
||||||
|
} else {
|
||||||
|
log.debug("SAM Detector monitoring timeout or stopped for node", node.id);
|
||||||
|
(node as any).samMonitoringActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start monitoring after a short delay
|
||||||
|
setTimeout(checkForChanges, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to handle SAM Detector result (using same logic as CanvasMask.handleMaskEditorClose)
|
||||||
|
async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageElement) {
|
||||||
|
try {
|
||||||
|
log.info("Handling SAM Detector result for node", node.id);
|
||||||
|
log.debug("Result image source:", resultImage.src.substring(0, 100) + '...');
|
||||||
|
|
||||||
|
const canvasWidget = (node as any).canvasWidget;
|
||||||
|
if (!canvasWidget || !canvasWidget.canvas) {
|
||||||
|
log.error("Canvas widget not available for SAM result processing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
|
||||||
|
|
||||||
|
// Wait for the result image to load (same as CanvasMask)
|
||||||
|
try {
|
||||||
|
// First check if the image is already loaded
|
||||||
|
if (resultImage.complete && resultImage.naturalWidth > 0) {
|
||||||
|
log.debug("SAM result image already loaded", {
|
||||||
|
width: resultImage.width,
|
||||||
|
height: resultImage.height
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Try to reload the image with a fresh request
|
||||||
|
log.debug("Attempting to reload SAM result image");
|
||||||
|
const originalSrc = resultImage.src;
|
||||||
|
|
||||||
|
// Add cache-busting parameter to force fresh load
|
||||||
|
const url = new URL(originalSrc);
|
||||||
|
url.searchParams.set('_t', Date.now().toString());
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
// Copy the loaded image data to the original image
|
||||||
|
resultImage.src = img.src;
|
||||||
|
resultImage.width = img.width;
|
||||||
|
resultImage.height = img.height;
|
||||||
|
log.debug("SAM result image reloaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: img.src
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to reload SAM result image", {
|
||||||
|
originalSrc: originalSrc,
|
||||||
|
newSrc: url.toString(),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
img.src = url.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to load image from SAM Detector.", error);
|
||||||
|
showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary canvas for mask processing (same as CanvasMask)
|
||||||
|
log.debug("Creating temporary canvas for mask processing");
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = canvas.width;
|
||||||
|
tempCanvas.height = canvas.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
if (tempCtx) {
|
||||||
|
tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
log.debug("Processing image data to create mask");
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
// Convert to mask format (same as CanvasMask)
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const originalAlpha = data[i + 3];
|
||||||
|
data[i] = 255;
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = 255 - originalAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert processed mask to image (same as CanvasMask)
|
||||||
|
log.debug("Converting processed mask to image");
|
||||||
|
const maskAsImage = new Image();
|
||||||
|
maskAsImage.src = tempCanvas.toDataURL();
|
||||||
|
await new Promise(resolve => maskAsImage.onload = resolve);
|
||||||
|
|
||||||
|
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||||
|
log.debug("Checking canvas and maskTool availability", {
|
||||||
|
hasCanvas: !!canvas,
|
||||||
|
hasMaskTool: !!canvas.maskTool,
|
||||||
|
maskToolType: typeof canvas.maskTool,
|
||||||
|
canvasKeys: Object.keys(canvas)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canvas.maskTool) {
|
||||||
|
log.error("MaskTool is not available. Canvas state:", {
|
||||||
|
hasCanvas: !!canvas,
|
||||||
|
canvasConstructor: canvas.constructor.name,
|
||||||
|
canvasKeys: Object.keys(canvas),
|
||||||
|
maskToolValue: canvas.maskTool
|
||||||
|
});
|
||||||
|
throw new Error("Mask tool not available or not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Applying SAM mask to canvas using addMask method");
|
||||||
|
|
||||||
|
// Use the addMask method which overlays on existing mask without clearing it
|
||||||
|
canvas.maskTool.addMask(maskAsImage);
|
||||||
|
|
||||||
|
// Update canvas and save state (same as CanvasMask)
|
||||||
|
canvas.render();
|
||||||
|
canvas.saveState();
|
||||||
|
|
||||||
|
// Create new preview image (same as CanvasMask)
|
||||||
|
log.debug("Creating new preview image");
|
||||||
|
const new_preview = new Image();
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
new_preview.src = URL.createObjectURL(blob);
|
||||||
|
await new Promise(r => new_preview.onload = r);
|
||||||
|
node.imgs = [new_preview];
|
||||||
|
log.debug("New preview image created successfully");
|
||||||
|
} else {
|
||||||
|
log.warn("Failed to create preview blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.render();
|
||||||
|
|
||||||
|
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error("Error processing SAM Detector result:", error);
|
||||||
|
|
||||||
|
// Show error notification
|
||||||
|
showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000);
|
||||||
|
} finally {
|
||||||
|
(node as any).samMonitoringActive = false;
|
||||||
|
(node as any).samOriginalImgSrc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show notifications
|
||||||
|
function showNotification(message: string, backgroundColor: string, duration: number) {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: ${backgroundColor};
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10001;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
notification.textContent = message;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to setup SAM Detector hook in menu options
|
||||||
|
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||||
|
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||||
|
const hookSAMDetector = () => {
|
||||||
|
const samDetectorIndex = options.findIndex((option) =>
|
||||||
|
option && option.content && (
|
||||||
|
option.content.includes("SAM Detector") ||
|
||||||
|
option.content === "Open in SAM Detector"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (samDetectorIndex !== -1) {
|
||||||
|
log.info(`Found SAM Detector menu item at index ${samDetectorIndex}: "${options[samDetectorIndex].content}"`);
|
||||||
|
const originalSamCallback = options[samDetectorIndex].callback;
|
||||||
|
options[samDetectorIndex].callback = async () => {
|
||||||
|
try {
|
||||||
|
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||||
|
|
||||||
|
// Automatically send canvas to clipspace and start monitoring
|
||||||
|
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
||||||
|
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
||||||
|
|
||||||
|
// Get the flattened canvas as blob
|
||||||
|
const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error("Failed to generate canvas blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the image to ComfyUI's temp storage
|
||||||
|
const formData = new FormData();
|
||||||
|
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp
|
||||||
|
formData.append("image", blob, filename);
|
||||||
|
formData.append("overwrite", "true");
|
||||||
|
formData.append("type", "temp");
|
||||||
|
|
||||||
|
const response = await api.fetchApi("/upload/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
log.debug('Image uploaded for SAM Detector:', data);
|
||||||
|
|
||||||
|
// Create image element with proper URL
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous"; // Add CORS support
|
||||||
|
|
||||||
|
// Wait for image to load before setting src
|
||||||
|
const imageLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
log.debug("SAM Detector image loaded successfully", {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
src: img.src.substring(0, 100) + '...'
|
||||||
|
});
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (error) => {
|
||||||
|
log.error("Failed to load SAM Detector image", error);
|
||||||
|
reject(new Error("Failed to load uploaded image"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set src after setting up event handlers
|
||||||
|
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||||
|
|
||||||
|
// Wait for image to load
|
||||||
|
await imageLoadPromise;
|
||||||
|
|
||||||
|
// Set the image to the node for clipspace
|
||||||
|
node.imgs = [img];
|
||||||
|
(node as any).clipspaceImg = img;
|
||||||
|
|
||||||
|
// Copy to ComfyUI clipspace
|
||||||
|
ComfyApp.copyToClipspace(node);
|
||||||
|
|
||||||
|
// Start monitoring for SAM Detector results
|
||||||
|
startSAMDetectorMonitoring(node);
|
||||||
|
|
||||||
|
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the original SAM Detector callback
|
||||||
|
if (originalSamCallback) {
|
||||||
|
await originalSamCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error("Error in SAM Detector hook:", e);
|
||||||
|
// Still try to call original callback
|
||||||
|
if (originalSamCallback) {
|
||||||
|
await originalSamCallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return true; // Found and hooked
|
||||||
|
}
|
||||||
|
return false; // Not found
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to hook immediately
|
||||||
|
if (!hookSAMDetector()) {
|
||||||
|
// If not found immediately, try again after Impact Pack adds it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (hookSAMDetector()) {
|
||||||
|
log.info("Successfully hooked SAM Detector after delay");
|
||||||
|
} else {
|
||||||
|
log.debug("SAM Detector menu item not found even after delay");
|
||||||
|
}
|
||||||
|
}, 150); // Slightly longer delay to ensure Impact Pack has added it
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,4 @@ import { LogLevel } from "./logger";
|
|||||||
|
|
||||||
// Log level for development.
|
// Log level for development.
|
||||||
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||||
export const LOG_LEVEL: keyof typeof LogLevel = 'DEBUG';
|
export const LOG_LEVEL: keyof typeof LogLevel = 'NONE';
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Layer {
|
|||||||
blendMode: string;
|
blendMode: string;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
mask?: Float32Array;
|
mask?: Float32Array;
|
||||||
|
flipH?: boolean;
|
||||||
|
flipV?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComfyNode {
|
export interface ComfyNode {
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ export function getStateSignature(layers: Layer[]): string {
|
|||||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||||
zIndex: layer.zIndex,
|
zIndex: layer.zIndex,
|
||||||
blendMode: layer.blendMode || 'normal',
|
blendMode: layer.blendMode || 'normal',
|
||||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
|
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
|
||||||
|
flipH: !!layer.flipH,
|
||||||
|
flipV: !!layer.flipV
|
||||||
};
|
};
|
||||||
|
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user