├── js ├── types.js ├── config.js ├── templates │ ├── mask_shortcuts.html │ ├── clipspace_clipboard_tooltip.html │ ├── system_clipboard_tooltip.html │ └── standard_shortcuts.html ├── ImageCache.js ├── utils │ ├── ResourceManager.js │ ├── LoggerUtils.js │ ├── ClipspaceUtils.js │ ├── ImageUploadUtils.js │ ├── mask_utils.js │ ├── WebSocketManager.js │ ├── PreviewUtils.js │ └── MaskProcessingUtils.js ├── state-saver.worker.js ├── css │ ├── blend_mode_menu.css │ ├── custom_shape_menu.css │ └── layers_panel.css ├── ShapeTool.js ├── CanvasSelection.js └── db.js ├── requirements.txt ├── python ├── __init__.py └── config.py ├── example_workflows ├── LayerForge_test_simple_workflow.jpg ├── LayerForge_test_simple_workflow.png ├── LayerForge_flux_fill_inpaint_example.jpg └── LayerForge_flux_fill_inpaint_example.png ├── src ├── config.ts ├── templates │ ├── mask_shortcuts.html │ ├── clipspace_clipboard_tooltip.html │ ├── system_clipboard_tooltip.html │ └── standard_shortcuts.html ├── ImageCache.ts ├── utils │ ├── ResourceManager.ts │ ├── LoggerUtils.ts │ ├── ClipspaceUtils.ts │ ├── ImageUploadUtils.ts │ ├── WebSocketManager.ts │ ├── mask_utils.ts │ └── PreviewUtils.ts ├── state-saver.worker.ts ├── css │ ├── blend_mode_menu.css │ ├── custom_shape_menu.css │ └── layers_panel.css ├── ShapeTool.ts ├── types.ts ├── CanvasSelection.ts └── db.ts ├── __init__.py ├── LAYERFORGE.md ├── CLONE.md ├── pyproject.toml ├── .github ├── workflows │ ├── publish.yml │ ├── release.yml │ ├── clone.yml │ └── ComfyUIdownloads.yml └── ISSUE_TEMPLATE │ ├── docs_request.yml │ ├── feature-request.yml │ └── bug_report.yml ├── LICENSE └── Doc ├── LitegraphService ├── MaskEditor ├── ComfyApp └── ComfyApi /js/types.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | torch 2 | torchvision 3 | transformers 4 | aiohttp 5 | numpy 6 | tqdm 7 | Pillow 8 | -------------------------------------------------------------------------------- /python/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'python' directory a package. 2 | from . import logger 3 | 4 | __all__ = ['logger'] 5 | -------------------------------------------------------------------------------- /python/config.py: -------------------------------------------------------------------------------- 1 | # Log level for development. 2 | # Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' 3 | LOG_LEVEL = 'NONE' 4 | -------------------------------------------------------------------------------- /js/config.js: -------------------------------------------------------------------------------- 1 | // Log level for development. 2 | // Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' 3 | export const LOG_LEVEL = 'NONE'; 4 | -------------------------------------------------------------------------------- /example_workflows/LayerForge_test_simple_workflow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azornes/Comfyui-LayerForge/HEAD/example_workflows/LayerForge_test_simple_workflow.jpg -------------------------------------------------------------------------------- /example_workflows/LayerForge_test_simple_workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azornes/Comfyui-LayerForge/HEAD/example_workflows/LayerForge_test_simple_workflow.png -------------------------------------------------------------------------------- /example_workflows/LayerForge_flux_fill_inpaint_example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azornes/Comfyui-LayerForge/HEAD/example_workflows/LayerForge_flux_fill_inpaint_example.jpg -------------------------------------------------------------------------------- /example_workflows/LayerForge_flux_fill_inpaint_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azornes/Comfyui-LayerForge/HEAD/example_workflows/LayerForge_flux_fill_inpaint_example.png -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "./logger"; 2 | 3 | // Log level for development. 4 | // Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' 5 | export const LOG_LEVEL: keyof typeof LogLevel = 'NONE'; 6 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | # Add the custom node's directory to the Python path 5 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 6 | 7 | from .canvas_node import LayerForgeNode 8 | 9 | LayerForgeNode.setup_routes() 10 | 11 | NODE_CLASS_MAPPINGS = { 12 | "LayerForgeNode": LayerForgeNode 13 | } 14 | 15 | NODE_DISPLAY_NAME_MAPPINGS = { 16 | "LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)" 17 | } 18 | 19 | WEB_DIRECTORY = "./js" 20 | 21 | __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] 22 | -------------------------------------------------------------------------------- /LAYERFORGE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Markdown** 4 | 5 | ```markdown 6 | [![Top LayerForge Node](https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json)](https://comfy.org) 7 | 8 | ``` 9 | 10 | **HTML** 11 | ```html 12 | Top LayerForge Node 13 | ``` 14 | -------------------------------------------------------------------------------- /CLONE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Markdown** 4 | 5 | ```markdown 6 | [![GitHub Clones](https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github)](https://github.com/MShawon/github-clone-count-badge) 7 | 8 | ``` 9 | 10 | **HTML** 11 | ```html 12 | GitHub Clones 13 | ``` 14 | -------------------------------------------------------------------------------- /js/templates/mask_shortcuts.html: -------------------------------------------------------------------------------- 1 |

Mask Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
Click + DragPaint on the mask
Middle Mouse Button + DragPan canvas view
Mouse WheelZoom view in/out
Brush ControlsUse sliders to control brush Size, Strength, and Hardness
Clear MaskRemove the entire mask
Exit ModeClick the "Draw Mask" button again
10 | -------------------------------------------------------------------------------- /src/templates/mask_shortcuts.html: -------------------------------------------------------------------------------- 1 |

Mask Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
Click + DragPaint on the mask
Middle Mouse Button + DragPan canvas view
Mouse WheelZoom view in/out
Brush ControlsUse sliders to control brush Size, Strength, and Hardness
Clear MaskRemove the entire mask
Exit ModeClick the "Draw Mask" button again
10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "layerforge" 3 | 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." 4 | version = "1.5.11" 5 | license = { text = "MIT License" } 6 | dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/Azornes/Comfyui-LayerForge" 10 | 11 | [tool.comfy] 12 | PublisherId = "azornes" 13 | DisplayName = "Comfyui-LayerForge" 14 | Icon = "" -------------------------------------------------------------------------------- /js/ImageCache.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | const log = createModuleLogger('ImageCache'); 3 | export class ImageCache { 4 | constructor() { 5 | this.cache = new Map(); 6 | } 7 | set(key, imageData) { 8 | log.info("Caching image data for key:", key); 9 | this.cache.set(key, imageData); 10 | } 11 | get(key) { 12 | const data = this.cache.get(key); 13 | log.debug("Retrieved cached data for key:", key, !!data); 14 | return data; 15 | } 16 | has(key) { 17 | return this.cache.has(key); 18 | } 19 | clear() { 20 | log.info("Clearing image cache"); 21 | this.cache.clear(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | permissions: 11 | issues: write 12 | 13 | jobs: 14 | publish-node: 15 | name: Publish Custom Node to registry 16 | runs-on: ubuntu-latest 17 | if: ${{ github.repository_owner == 'Azornes' }} 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - name: Publish Custom Node 22 | uses: Comfy-Org/publish-node-action@main 23 | with: 24 | ## Add your own personal access token to your Github Repository secrets and reference it here. 25 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /src/ImageCache.ts: -------------------------------------------------------------------------------- 1 | import {createModuleLogger} from "./utils/LoggerUtils.js"; 2 | import type { ImageDataPixel } from './types'; 3 | 4 | const log = createModuleLogger('ImageCache'); 5 | 6 | export class ImageCache { 7 | private cache: Map; 8 | 9 | constructor() { 10 | this.cache = new Map(); 11 | } 12 | 13 | set(key: string, imageData: ImageDataPixel): void { 14 | log.info("Caching image data for key:", key); 15 | this.cache.set(key, imageData); 16 | } 17 | 18 | get(key: string): ImageDataPixel | undefined { 19 | const data = this.cache.get(key); 20 | log.debug("Retrieved cached data for key:", key, !!data); 21 | return data; 22 | } 23 | 24 | has(key: string): boolean { 25 | return this.cache.has(key); 26 | } 27 | 28 | clear(): void { 29 | log.info("Clearing image cache"); 30 | this.cache.clear(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/templates/clipspace_clipboard_tooltip.html: -------------------------------------------------------------------------------- 1 |

📋 ComfyUI Clipspace Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Ctrl + CCopy selected layers to internal clipboard + ComfyUI Clipspace as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ ComfyUI Clipspace (workflow images)
3️⃣ System clipboard (fallback)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
11 |
12 | 💡 Bestt for: ComfyUI workflow integration and node-to-node image transfer 13 |
14 | -------------------------------------------------------------------------------- /src/templates/clipspace_clipboard_tooltip.html: -------------------------------------------------------------------------------- 1 |

📋 ComfyUI Clipspace Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Ctrl + CCopy selected layers to internal clipboard + ComfyUI Clipspace as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ ComfyUI Clipspace (workflow images)
3️⃣ System clipboard (fallback)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
11 |
12 | 💡 Bestt for: ComfyUI workflow integration and node-to-node image transfer 13 |
14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Azornes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs_request.yml: -------------------------------------------------------------------------------- 1 | name: 📝 Documentation Request 2 | description: Suggest improvements or additions to documentation 3 | title: "[Docs] " 4 | labels: [documentation] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | > This template is only for suggesting improvements or additions **to existing documentation**. 10 | > If you want to suggest a new feature, functionality, or enhancement for the project itself, please use the **Feature Request** template instead. 11 | > Thank you! 12 | - type: input 13 | id: doc_area 14 | attributes: 15 | label: Area of documentation 16 | placeholder: e.g. Key Features, Installation, Controls & Shortcuts 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: current_issue 21 | attributes: 22 | label: What's wrong or missing? 23 | placeholder: Describe the gap or confusing part 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: suggested_content 28 | attributes: 29 | label: How should it be improved? 30 | placeholder: Provide concrete suggestions or examples 31 | -------------------------------------------------------------------------------- /js/templates/system_clipboard_tooltip.html: -------------------------------------------------------------------------------- 1 |

📋 System Clipboard Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Ctrl + CCopy selected layers to internal clipboard + system clipboard as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ System clipboard (images, screenshots)
3️⃣ System clipboard (file paths, URLs)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
11 |
12 | ⚠️ Security Note: "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop. 13 |
14 |
15 | 💡 Best for: Working with screenshots, copied images, file paths, and urls. 16 |
17 | -------------------------------------------------------------------------------- /src/templates/system_clipboard_tooltip.html: -------------------------------------------------------------------------------- 1 |

📋 System Clipboard Mode

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Ctrl + CCopy selected layers to internal clipboard + system clipboard as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ System clipboard (images, screenshots)
3️⃣ System clipboard (file paths, URLs)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
11 |
12 | ⚠️ Security Note: "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop. 13 |
14 |
15 | 💡 Best for: Working with screenshots, copied images, file paths, and urls. 16 |
17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: ✨ Feature Request 2 | description: Suggest an idea for this project 3 | title: '[Feature Request]: ' 4 | labels: ['enhancement'] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## Before suggesting a new feature... 10 | Please make sure of the following: 11 | 12 | 1. You are using the latest version of the project 13 | 2. The functionality you want to propose does not already exist 14 | 15 | I also recommend using an AI assistant to check whether the feature is already included. 16 | To do this, simply: 17 | 18 | - Copy and paste the entire **README.md** file 19 | - Ask if your desired feature is already covered 20 | 21 | This helps to avoid duplicate requests for features that are already available. 22 | - type: markdown 23 | attributes: 24 | value: | 25 | *Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible* 26 | - type: textarea 27 | id: feature 28 | attributes: 29 | label: What would your feature do ? 30 | description: Tell me about your feature in a very clear and simple way, and what problem it would solve 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: workflow 35 | attributes: 36 | label: Proposed workflow 37 | description: Please provide me with step by step information on how you'd like the feature to be accessed and used 38 | value: | 39 | 1. Go to .... 40 | 2. Press .... 41 | 3. ... 42 | validations: 43 | required: true 44 | - type: textarea 45 | id: misc 46 | attributes: 47 | label: Additional information 48 | description: Add any other context or screenshots about the feature request here. 49 | -------------------------------------------------------------------------------- /js/utils/ResourceManager.js: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { $el } from "../../../scripts/ui.js"; 3 | import { createModuleLogger } from "./LoggerUtils.js"; 4 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 5 | const log = createModuleLogger('ResourceManager'); 6 | export const addStylesheet = withErrorHandling(function (url) { 7 | if (!url) { 8 | throw createValidationError("URL is required", { url }); 9 | } 10 | log.debug('Adding stylesheet:', { url }); 11 | if (url.endsWith(".js")) { 12 | url = url.substr(0, url.length - 2) + "css"; 13 | } 14 | $el("link", { 15 | parent: document.head, 16 | rel: "stylesheet", 17 | type: "text/css", 18 | href: url.startsWith("http") ? url : getUrl(url), 19 | }); 20 | log.debug('Stylesheet added successfully:', { finalUrl: url }); 21 | }, 'addStylesheet'); 22 | export function getUrl(path, baseUrl) { 23 | if (!path) { 24 | throw createValidationError("Path is required", { path }); 25 | } 26 | if (baseUrl) { 27 | return new URL(path, baseUrl).toString(); 28 | } 29 | else { 30 | // @ts-ignore 31 | return new URL("../" + path, import.meta.url).toString(); 32 | } 33 | } 34 | export const loadTemplate = withErrorHandling(async function (path, baseUrl) { 35 | if (!path) { 36 | throw createValidationError("Path is required", { path }); 37 | } 38 | const url = getUrl(path, baseUrl); 39 | log.debug('Loading template:', { path, url }); 40 | const response = await fetch(url); 41 | if (!response.ok) { 42 | throw createNetworkError(`Failed to load template: ${url}`, { 43 | url, 44 | status: response.status, 45 | statusText: response.statusText 46 | }); 47 | } 48 | const content = await response.text(); 49 | log.debug('Template loaded successfully:', { path, contentLength: content.length }); 50 | return content; 51 | }, 'loadTemplate'); 52 | -------------------------------------------------------------------------------- /Doc/LitegraphService: -------------------------------------------------------------------------------- 1 | LitegraphService Documentation 2 | 3 | Main functions of useLitegraphService() 4 | 5 | Node Registration and Creation Functions: 6 | 7 | registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) 8 | 9 | - Registers node definition in LiteGraph system 10 | - Creates ComfyNode class with inputs, outputs and widgets 11 | - Adds context menu, background drawing and keyboard handling 12 | - Invokes extensions before registration 13 | 14 | addNodeOnGraph(nodeDef, options) 15 | 16 | - Adds new node to graph at specified position 17 | - By default places node at canvas center 18 | 19 | Navigation and View Functions: 20 | 21 | getCanvasCenter(): Vector2 22 | 23 | - Returns canvas center coordinates accounting for DPI 24 | 25 | goToNode(nodeId: NodeId) 26 | 27 | - Animates transition to specified node on canvas 28 | 29 | resetView() 30 | 31 | - Resets canvas view to default settings (scale 1, offset [0,0]) 32 | 33 | fitView() 34 | 35 | - Fits canvas view to show all nodes 36 | 37 | Node Handling Functions (internal): 38 | 39 | addNodeContextMenuHandler(node) 40 | 41 | - Adds context menu with options: 42 | 43 | - Open/Copy/Save image (for image nodes) 44 | - Bypass node 45 | - Copy/Paste to Clipspace 46 | - Open in MaskEditor (for image nodes) 47 | 48 | addDrawBackgroundHandler(node) 49 | 50 | - Adds node background drawing logic 51 | - Handles image, animation and video previews 52 | - Manages thumbnail display 53 | 54 | addNodeKeyHandler(node) 55 | 56 | - Adds keyboard handling: 57 | 58 | - Left/Right arrows: navigate between images 59 | - Escape: close image preview 60 | 61 | ComfyNode Class (created by registerNodeDef): 62 | 63 | Main methods: 64 | 65 | - #addInputs() - adds inputs and widgets to node 66 | - #addOutputs() - adds outputs to node 67 | - configure() - configures node from serialized data 68 | - #setupStrokeStyles() - sets border styles (errors, execution, etc.) 69 | 70 | Properties: 71 | 72 | - comfyClass - ComfyUI class name 73 | - nodeData - node definition 74 | - Automatic yellow coloring for API nodes 75 | 76 | -------------------------------------------------------------------------------- /src/utils/ResourceManager.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { $el } from "../../../scripts/ui.js"; 3 | import { createModuleLogger } from "./LoggerUtils.js"; 4 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 5 | 6 | const log = createModuleLogger('ResourceManager'); 7 | 8 | export const addStylesheet = withErrorHandling(function(url: string): void { 9 | if (!url) { 10 | throw createValidationError("URL is required", { url }); 11 | } 12 | 13 | log.debug('Adding stylesheet:', { url }); 14 | 15 | if (url.endsWith(".js")) { 16 | url = url.substr(0, url.length - 2) + "css"; 17 | } 18 | $el("link", { 19 | parent: document.head, 20 | rel: "stylesheet", 21 | type: "text/css", 22 | href: url.startsWith("http") ? url : getUrl(url), 23 | }); 24 | 25 | log.debug('Stylesheet added successfully:', { finalUrl: url }); 26 | }, 'addStylesheet'); 27 | 28 | export function getUrl(path: string, baseUrl?: string | URL): string { 29 | if (!path) { 30 | throw createValidationError("Path is required", { path }); 31 | } 32 | 33 | if (baseUrl) { 34 | return new URL(path, baseUrl).toString(); 35 | } else { 36 | // @ts-ignore 37 | return new URL("../" + path, import.meta.url).toString(); 38 | } 39 | } 40 | 41 | export const loadTemplate = withErrorHandling(async function(path: string, baseUrl?: string | URL): Promise { 42 | if (!path) { 43 | throw createValidationError("Path is required", { path }); 44 | } 45 | 46 | const url = getUrl(path, baseUrl); 47 | log.debug('Loading template:', { path, url }); 48 | 49 | const response = await fetch(url); 50 | if (!response.ok) { 51 | throw createNetworkError(`Failed to load template: ${url}`, { 52 | url, 53 | status: response.status, 54 | statusText: response.statusText 55 | }); 56 | } 57 | 58 | const content = await response.text(); 59 | log.debug('Template loaded successfully:', { path, contentLength: content.length }); 60 | return content; 61 | }, 'loadTemplate'); 62 | -------------------------------------------------------------------------------- /js/templates/standard_shortcuts.html: -------------------------------------------------------------------------------- 1 |

Canvas Control

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Click + DragPan canvas view
Mouse WheelZoom view in/out
Shift + Click (background)Start resizing canvas area
Shift + Ctrl + ClickStart moving entire canvas
Shift + S + Left ClickDraw custom shape for output area
Single Click (background)Deselect all layers
EscClose fullscreen editor mode
11 | 12 |

Clipboard & I/O

13 | 14 | 15 | 16 | 17 |
Ctrl + CCopy selected layer(s)
Ctrl + VPaste from clipboard (image or internal layers)
Drag & Drop Image FileAdd image as a new layer
18 | 19 |

Layer Interaction

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
Click + DragMove selected layer(s)
Ctrl + ClickAdd/Remove layer from selection
Alt + DragClone selected layer(s)
Right ClickShow blend mode & opacity menu
Mouse WheelScale layer (snaps to grid)
Ctrl + Mouse WheelFine-scale layer
Shift + Mouse WheelRotate layer by 5° steps
Shift + Ctrl + Mouse WheelSnap rotation to 5° increments
Arrow KeysNudge layer by 1px
Shift + Arrow KeysNudge layer by 10px
[ or ]Rotate by 1°
Shift + [ or ]Rotate by 10°
DeleteDelete selected layer(s)
35 | 36 |

Transform Handles (on selected layer)

37 | 38 | 39 | 40 | 41 | 42 |
Drag Corner/SideResize layer
Drag Rotation HandleRotate layer
Hold ShiftKeep aspect ratio / Snap rotation to 15°
Hold CtrlSnap to grid
43 | -------------------------------------------------------------------------------- /src/templates/standard_shortcuts.html: -------------------------------------------------------------------------------- 1 |

Canvas Control

2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
Click + DragPan canvas view
Mouse WheelZoom view in/out
Shift + Click (background)Start resizing canvas area
Shift + Ctrl + ClickStart moving entire canvas
Shift + S + Left ClickDraw custom shape for output area
Single Click (background)Deselect all layers
EscClose fullscreen editor mode
11 | 12 |

Clipboard & I/O

13 | 14 | 15 | 16 | 17 |
Ctrl + CCopy selected layer(s)
Ctrl + VPaste from clipboard (image or internal layers)
Drag & Drop Image FileAdd image as a new layer
18 | 19 |

Layer Interaction

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
Click + DragMove selected layer(s)
Ctrl + ClickAdd/Remove layer from selection
Alt + DragClone selected layer(s)
Right ClickShow blend mode & opacity menu
Mouse WheelScale layer (snaps to grid)
Ctrl + Mouse WheelFine-scale layer
Shift + Mouse WheelRotate layer by 5° steps
Shift + Ctrl + Mouse WheelSnap rotation to 5° increments
Arrow KeysNudge layer by 1px
Shift + Arrow KeysNudge layer by 10px
[ or ]Rotate by 1°
Shift + [ or ]Rotate by 10°
DeleteDelete selected layer(s)
35 | 36 |

Transform Handles (on selected layer)

37 | 38 | 39 | 40 | 41 | 42 |
Drag Corner/SideResize layer
Drag Rotation HandleRotate layer
Hold ShiftKeep aspect ratio / Snap rotation to 15°
Hold CtrlSnap to grid
43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Auto Release with Version Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 # Pobierz pełną historię Git (potrzebne do git log) 17 | 18 | - name: Extract base version from pyproject.toml 19 | id: version 20 | run: | 21 | base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') 22 | echo "base_version=$base" >> $GITHUB_OUTPUT 23 | 24 | - name: Check if tag for this version already exists 25 | run: | 26 | TAG="v${{ steps.version.outputs.base_version }}" 27 | git fetch --tags 28 | if git rev-parse "$TAG" >/dev/null 2>&1; then 29 | echo "Tag $TAG already exists. Skipping release." 30 | exit 0 31 | fi 32 | 33 | - name: Set version tag 34 | id: unique_tag 35 | run: | 36 | echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT 37 | 38 | # ZMIANA: Poprawione obsługa multi-line output (z delimiterem EOF, bez zastępowania \n) 39 | - name: Get commit history since last version tag 40 | id: commit_history 41 | run: | 42 | VERSION_TAG="v${{ steps.version.outputs.base_version }}" 43 | git fetch --tags 44 | 45 | if git rev-parse "$VERSION_TAG" >/dev/null 2>&1; then 46 | RANGE="$VERSION_TAG..HEAD" 47 | else 48 | RANGE="HEAD" 49 | fi 50 | 51 | HISTORY=$(git log --pretty=format:"%s" $RANGE | \ 52 | grep -vE '^\s*(add|update|fix|change|edit|mod|modify|cleanup|misc|typo|readme|temp|test|debug)\b' | \ 53 | grep -vE '^(\s*Update|Add|Fix|Change|Edit|Refactor|Bump|Minor|Misc|Readme|Test)[^a-zA-Z0-9]*$' | \ 54 | sed 's/^/- /') 55 | 56 | if [ -z "$HISTORY" ]; then 57 | HISTORY="No significant changes since last release." 58 | fi 59 | 60 | echo "commit_history<> $GITHUB_OUTPUT 61 | echo "$HISTORY" >> $GITHUB_OUTPUT 62 | echo "EOF" >> $GITHUB_OUTPUT 63 | 64 | - name: Create GitHub Release 65 | uses: softprops/action-gh-release@v1 66 | with: 67 | tag_name: ${{ steps.unique_tag.outputs.final_tag }} 68 | name: Release ${{ steps.unique_tag.outputs.final_tag }} 69 | generate_release_notes: true 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /Doc/MaskEditor: -------------------------------------------------------------------------------- 1 | MASKEDITOR.TS FUNCTION DOCUMENTATION 2 | 3 | MaskEditorDialog - Main mask editor class 4 | 5 | - getInstance() - Singleton pattern, returns editor instance 6 | - show() - Opens the mask editor 7 | - save() - Saves mask to server 8 | - destroy() - Closes and cleans up editor 9 | - isOpened() - Checks if editor is open 10 | 11 | CanvasHistory - Change history management 12 | 13 | - saveState() - Saves current canvas state 14 | - undo() - Undo last operation 15 | - redo() - Redo undone operation 16 | - clearStates() - Clears history 17 | 18 | BrushTool - Brush tool 19 | 20 | - setBrushSize(size) - Sets brush size 21 | - setBrushOpacity(opacity) - Sets brush opacity 22 | - setBrushHardness(hardness) - Sets brush hardness 23 | - setBrushType(type) - Sets brush shape (circle/square) 24 | - startDrawing() - Starts drawing 25 | - handleDrawing() - Handles drawing during movement 26 | - drawEnd() - Ends drawing 27 | 28 | PaintBucketTool - Fill tool 29 | 30 | - floodFill(point) - Fills area with color from point 31 | - setTolerance(tolerance) - Sets color tolerance 32 | - setFillOpacity(opacity) - Sets fill opacity 33 | - invertMask() - Inverts mask 34 | 35 | ColorSelectTool - Color selection tool 36 | 37 | - fillColorSelection(point) - Selects similar colors 38 | - setTolerance(tolerance) - Sets selection tolerance 39 | - setLivePreview(enabled) - Enables/disables live preview 40 | - setComparisonMethod(method) - Sets color comparison method 41 | - setApplyWholeImage(enabled) - Applies to whole image 42 | - setSelectOpacity(opacity) - Sets selection opacity 43 | 44 | UIManager - Interface management 45 | 46 | - updateBrushPreview() - Updates brush preview 47 | - setBrushVisibility(visible) - Shows/hides brush 48 | - screenToCanvas(coords) - Converts screen coordinates to canvas 49 | - getMaskColor() - Returns mask color 50 | - setSaveButtonEnabled(enabled) - Enables/disables save button 51 | 52 | ToolManager - Tool management 53 | 54 | - setTool(tool) - Sets active tool 55 | - getCurrentTool() - Returns active tool 56 | - handlePointerDown/Move/Up() - Handles mouse/touch events 57 | 58 | PanAndZoomManager - View management 59 | 60 | - zoom(event) - Zooms in/out canvas 61 | - handlePanStart/Move() - Handles canvas panning 62 | - initializeCanvasPanZoom() - Initializes canvas view 63 | - smoothResetView() - Smoothly resets view 64 | 65 | MessageBroker - Communication system 66 | 67 | - publish(topic, data) - Publishes message 68 | - subscribe(topic, callback) - Subscribes to topic 69 | - pull(topic, data) - Pulls data from topic 70 | - createPullTopic/PushTopic() - Creates communication topics 71 | 72 | KeyboardManager - Keyboard handling 73 | 74 | - addListeners() - Adds keyboard listeners 75 | - removeListeners() - Removes listeners 76 | - isKeyDown(key) - Checks if key is pressed 77 | -------------------------------------------------------------------------------- /js/utils/LoggerUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * LoggerUtils - Centralizacja inicjalizacji loggerów 3 | * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module 4 | */ 5 | import { logger, LogLevel } from "../logger.js"; 6 | import { LOG_LEVEL } from '../config.js'; 7 | /** 8 | * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami 9 | * @param {string} moduleName - Nazwa modułu 10 | * @returns {Logger} Obiekt z metodami logowania 11 | */ 12 | export function createModuleLogger(moduleName) { 13 | logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]); 14 | return { 15 | debug: (...args) => logger.debug(moduleName, ...args), 16 | info: (...args) => logger.info(moduleName, ...args), 17 | warn: (...args) => logger.warn(moduleName, ...args), 18 | error: (...args) => logger.error(moduleName, ...args) 19 | }; 20 | } 21 | /** 22 | * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL 23 | * @returns {Logger} Obiekt z metodami logowania 24 | */ 25 | export function createAutoLogger() { 26 | const stack = new Error().stack; 27 | const match = stack?.match(/\/([^\/]+)\.js/); 28 | const moduleName = match ? match[1] : 'Unknown'; 29 | return createModuleLogger(moduleName); 30 | } 31 | /** 32 | * Wrapper dla operacji z automatycznym logowaniem błędów 33 | * @param {Function} operation - Operacja do wykonania 34 | * @param {Logger} log - Obiekt loggera 35 | * @param {string} operationName - Nazwa operacji (dla logów) 36 | * @returns {Function} Opakowana funkcja 37 | */ 38 | export function withErrorLogging(operation, log, operationName) { 39 | return async function (...args) { 40 | try { 41 | log.debug(`Starting ${operationName}`); 42 | const result = await operation.apply(this, args); 43 | log.debug(`Completed ${operationName}`); 44 | return result; 45 | } 46 | catch (error) { 47 | log.error(`Error in ${operationName}:`, error); 48 | throw error; 49 | } 50 | }; 51 | } 52 | /** 53 | * Decorator dla metod klasy z automatycznym logowaniem 54 | * @param {Logger} log - Obiekt loggera 55 | * @param {string} methodName - Nazwa metody 56 | */ 57 | export function logMethod(log, methodName) { 58 | return function (target, propertyKey, descriptor) { 59 | const originalMethod = descriptor.value; 60 | descriptor.value = async function (...args) { 61 | try { 62 | log.debug(`${methodName || propertyKey} started`); 63 | const result = await originalMethod.apply(this, args); 64 | log.debug(`${methodName || propertyKey} completed`); 65 | return result; 66 | } 67 | catch (error) { 68 | log.error(`${methodName || propertyKey} failed:`, error); 69 | throw error; 70 | } 71 | }; 72 | return descriptor; 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /js/state-saver.worker.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | console.log('[StateWorker] Worker script loaded and running.'); 3 | const DB_NAME = 'CanvasNodeDB'; 4 | const STATE_STORE_NAME = 'CanvasState'; 5 | const DB_VERSION = 3; 6 | let db; 7 | function log(...args) { 8 | console.log('[StateWorker]', ...args); 9 | } 10 | function error(...args) { 11 | console.error('[StateWorker]', ...args); 12 | } 13 | function createDBRequest(store, operation, data, errorMessage) { 14 | return new Promise((resolve, reject) => { 15 | let request; 16 | switch (operation) { 17 | case 'put': 18 | request = store.put(data); 19 | break; 20 | default: 21 | reject(new Error(`Unknown operation: ${operation}`)); 22 | return; 23 | } 24 | request.onerror = (event) => { 25 | error(errorMessage, event.target.error); 26 | reject(errorMessage); 27 | }; 28 | request.onsuccess = (event) => { 29 | resolve(event.target.result); 30 | }; 31 | }); 32 | } 33 | function openDB() { 34 | return new Promise((resolve, reject) => { 35 | if (db) { 36 | resolve(db); 37 | return; 38 | } 39 | const request = indexedDB.open(DB_NAME, DB_VERSION); 40 | request.onerror = (event) => { 41 | error("IndexedDB error:", event.target.error); 42 | reject("Error opening IndexedDB."); 43 | }; 44 | request.onsuccess = (event) => { 45 | db = event.target.result; 46 | log("IndexedDB opened successfully in worker."); 47 | resolve(db); 48 | }; 49 | request.onupgradeneeded = (event) => { 50 | log("Upgrading IndexedDB in worker..."); 51 | const tempDb = event.target.result; 52 | if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { 53 | tempDb.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' }); 54 | } 55 | }; 56 | }); 57 | } 58 | async function setCanvasState(id, state) { 59 | const db = await openDB(); 60 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 61 | const store = transaction.objectStore(STATE_STORE_NAME); 62 | await createDBRequest(store, 'put', { id, state }, "Error setting canvas state"); 63 | } 64 | self.onmessage = async function (e) { 65 | log('Message received from main thread:', e.data ? 'data received' : 'no data'); 66 | const { state, nodeId } = e.data; 67 | if (!state || !nodeId) { 68 | error('Invalid data received from main thread'); 69 | return; 70 | } 71 | try { 72 | log(`Saving state for node: ${nodeId}`); 73 | await setCanvasState(nodeId, state); 74 | log(`State saved successfully for node: ${nodeId}`); 75 | } 76 | catch (err) { 77 | error(`Failed to save state for node: ${nodeId}`, err); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/state-saver.worker.ts: -------------------------------------------------------------------------------- 1 | console.log('[StateWorker] Worker script loaded and running.'); 2 | 3 | const DB_NAME = 'CanvasNodeDB'; 4 | const STATE_STORE_NAME = 'CanvasState'; 5 | const DB_VERSION = 3; 6 | 7 | let db: IDBDatabase | null; 8 | 9 | function log(...args: any[]): void { 10 | console.log('[StateWorker]', ...args); 11 | } 12 | 13 | function error(...args: any[]): void { 14 | console.error('[StateWorker]', ...args); 15 | } 16 | 17 | function createDBRequest(store: IDBObjectStore, operation: 'put', data: any, errorMessage: string): Promise { 18 | return new Promise((resolve, reject) => { 19 | let request: IDBRequest; 20 | switch (operation) { 21 | case 'put': 22 | request = store.put(data); 23 | break; 24 | default: 25 | reject(new Error(`Unknown operation: ${operation}`)); 26 | return; 27 | } 28 | 29 | request.onerror = (event) => { 30 | error(errorMessage, (event.target as IDBRequest).error); 31 | reject(errorMessage); 32 | }; 33 | 34 | request.onsuccess = (event) => { 35 | resolve((event.target as IDBRequest).result); 36 | }; 37 | }); 38 | } 39 | 40 | function openDB(): Promise { 41 | return new Promise((resolve, reject) => { 42 | if (db) { 43 | resolve(db); 44 | return; 45 | } 46 | 47 | const request = indexedDB.open(DB_NAME, DB_VERSION); 48 | 49 | request.onerror = (event) => { 50 | error("IndexedDB error:", (event.target as IDBOpenDBRequest).error); 51 | reject("Error opening IndexedDB."); 52 | }; 53 | 54 | request.onsuccess = (event) => { 55 | db = (event.target as IDBOpenDBRequest).result; 56 | log("IndexedDB opened successfully in worker."); 57 | resolve(db); 58 | }; 59 | 60 | request.onupgradeneeded = (event) => { 61 | log("Upgrading IndexedDB in worker..."); 62 | const tempDb = (event.target as IDBOpenDBRequest).result; 63 | if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { 64 | tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); 65 | } 66 | }; 67 | }); 68 | } 69 | 70 | async function setCanvasState(id: string, state: any): Promise { 71 | const db = await openDB(); 72 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 73 | const store = transaction.objectStore(STATE_STORE_NAME); 74 | await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); 75 | } 76 | 77 | self.onmessage = async function(e: MessageEvent<{ state: any, nodeId: string }>): Promise { 78 | log('Message received from main thread:', e.data ? 'data received' : 'no data'); 79 | const { state, nodeId } = e.data; 80 | 81 | if (!state || !nodeId) { 82 | error('Invalid data received from main thread'); 83 | return; 84 | } 85 | 86 | try { 87 | log(`Saving state for node: ${nodeId}`); 88 | await setCanvasState(nodeId, state); 89 | log(`State saved successfully for node: ${nodeId}`); 90 | } catch (err) { 91 | error(`Failed to save state for node: ${nodeId}`, err); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /Doc/ComfyApp: -------------------------------------------------------------------------------- 1 | ## __Main ComfyApp Functions__ import { app, ComfyApp } from "../../scripts/app.js"; 2 | 3 | ### __Application Management__ 4 | 5 | - `setup(canvasEl)` - Initializes the application on the page, loads extensions, registers nodes 6 | - `resizeCanvas()` - Adjusts canvas size to window 7 | - `clean()` - Clears application state (node outputs, image previews, errors) 8 | 9 | ### __Workflow Management__ 10 | 11 | - `loadGraphData(graphData, clean, restore_view, workflow, options)` - Loads workflow data from JSON 12 | - `loadApiJson(apiData, fileName)` - Loads workflow from API format 13 | - `graphToPrompt(graph, options)` - Converts graph to prompt for execution 14 | - `handleFile(file)` - Handles file loading (PNG, WebP, JSON, MP3, MP4, SVG, etc.) 15 | 16 | ### __Execution__ 17 | 18 | - `queuePrompt(number, batchCount, queueNodeIds)` - Queues prompt for execution 19 | - `registerNodes()` - Registers node definitions from backend 20 | - `registerNodeDef(nodeId, nodeDef)` - Registers single node definition 21 | - `refreshComboInNodes()` - Refreshes combo lists in nodes 22 | 23 | ### __Node Management__ 24 | 25 | - `registerExtension(extension)` - Registers ComfyUI extension 26 | - `updateVueAppNodeDefs(defs)` - Updates node definitions in Vue app 27 | - `revokePreviews(nodeId)` - Frees memory for node previews 28 | 29 | ### __Clipboard__ 30 | 31 | - `copyToClipspace(node)` - Copies node to clipboard 32 | - `pasteFromClipspace(node)` - Pastes data from clipboard to node 33 | 34 | ### __Position Conversion__ 35 | 36 | - `clientPosToCanvasPos(pos)` - Converts client position to canvas position 37 | - `canvasPosToClientPos(pos)` - Converts canvas position to client position 38 | 39 | ### __Error Handling__ 40 | 41 | - `showErrorOnFileLoad(file)` - Displays file loading error 42 | - `#showMissingNodesError(missingNodeTypes)` - Shows missing nodes error 43 | - `#showMissingModelsError(missingModels, paths)` - Shows missing models error 44 | 45 | ### __Internal Handlers__ 46 | 47 | - `#addDropHandler()` - Handles drag and drop of files 48 | - `#addProcessKeyHandler()` - Handles keyboard input 49 | - `#addDrawNodeHandler()` - Modifies node drawing behavior 50 | - `#addApiUpdateHandlers()` - Handles API updates 51 | - `#addConfigureHandler()` - Graph configuration flag 52 | - `#addAfterConfigureHandler()` - Post-configuration handling 53 | 54 | ### __Deprecated Properties__ 55 | 56 | Many properties are marked as deprecated and redirect to appropriate stores: 57 | 58 | - `lastNodeErrors` → `useExecutionStore().lastNodeErrors` 59 | - `lastExecutionError` → `useExecutionStore().lastExecutionError` 60 | - `runningNodeId` → `useExecutionStore().executingNodeId` 61 | - `shiftDown` → `useWorkspaceStore().shiftDown` 62 | - `widgets` → `useWidgetStore().widgets` 63 | - `extensions` → `useExtensionStore().extensions` 64 | 65 | ### __Utility Functions__ 66 | 67 | - `sanitizeNodeName(string)` - Cleans node name from dangerous characters 68 | - `getPreviewFormatParam()` - Returns preview format parameter 69 | - `getRandParam()` - Returns random parameter for refresh 70 | - `isApiJson(data)` - Checks if data is in API JSON format 71 | 72 | This application uses Vue and TypeScript composition pattern, where many functionalities are separated into different services and stores (e.g., `useExecutionStore`, `useWorkflowService`, `useExtensionService`, etc.). 73 | -------------------------------------------------------------------------------- /src/utils/LoggerUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * LoggerUtils - Centralizacja inicjalizacji loggerów 3 | * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module 4 | */ 5 | 6 | import {logger, LogLevel} from "../logger.js"; 7 | import { LOG_LEVEL } from '../config.js'; 8 | 9 | export interface Logger { 10 | debug: (...args: any[]) => void; 11 | info: (...args: any[]) => void; 12 | warn: (...args: any[]) => void; 13 | error: (...args: any[]) => void; 14 | } 15 | 16 | /** 17 | * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami 18 | * @param {string} moduleName - Nazwa modułu 19 | * @returns {Logger} Obiekt z metodami logowania 20 | */ 21 | export function createModuleLogger(moduleName: string): Logger { 22 | logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]); 23 | 24 | return { 25 | debug: (...args: any[]) => logger.debug(moduleName, ...args), 26 | info: (...args: any[]) => logger.info(moduleName, ...args), 27 | warn: (...args: any[]) => logger.warn(moduleName, ...args), 28 | error: (...args: any[]) => logger.error(moduleName, ...args) 29 | }; 30 | } 31 | 32 | /** 33 | * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL 34 | * @returns {Logger} Obiekt z metodami logowania 35 | */ 36 | export function createAutoLogger(): Logger { 37 | const stack = new Error().stack; 38 | const match = stack?.match(/\/([^\/]+)\.js/); 39 | const moduleName = match ? match[1] : 'Unknown'; 40 | 41 | return createModuleLogger(moduleName); 42 | } 43 | 44 | /** 45 | * Wrapper dla operacji z automatycznym logowaniem błędów 46 | * @param {Function} operation - Operacja do wykonania 47 | * @param {Logger} log - Obiekt loggera 48 | * @param {string} operationName - Nazwa operacji (dla logów) 49 | * @returns {Function} Opakowana funkcja 50 | */ 51 | export function withErrorLogging any>( 52 | operation: T, 53 | log: Logger, 54 | operationName: string 55 | ): (...args: Parameters) => Promise> { 56 | return async function(this: any, ...args: Parameters): Promise> { 57 | try { 58 | log.debug(`Starting ${operationName}`); 59 | const result = await operation.apply(this, args); 60 | log.debug(`Completed ${operationName}`); 61 | return result; 62 | } catch (error) { 63 | log.error(`Error in ${operationName}:`, error); 64 | throw error; 65 | } 66 | }; 67 | } 68 | 69 | /** 70 | * Decorator dla metod klasy z automatycznym logowaniem 71 | * @param {Logger} log - Obiekt loggera 72 | * @param {string} methodName - Nazwa metody 73 | */ 74 | export function logMethod(log: Logger, methodName?: string) { 75 | return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 76 | const originalMethod = descriptor.value; 77 | 78 | descriptor.value = async function (...args: any[]) { 79 | try { 80 | log.debug(`${methodName || propertyKey} started`); 81 | const result = await originalMethod.apply(this, args); 82 | log.debug(`${methodName || propertyKey} completed`); 83 | return result; 84 | } catch (error) { 85 | log.error(`${methodName || propertyKey} failed:`, error); 86 | throw error; 87 | } 88 | }; 89 | 90 | return descriptor; 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /Doc/ComfyApi: -------------------------------------------------------------------------------- 1 | # ComfyApi - Function Documentation Summary import { api } from "../../scripts/api.js"; 2 | 3 | ## Basic Information 4 | 5 | ComfyApi is a class for communication with ComfyUI backend via WebSocket and REST API. 6 | 7 | ## Main Functions: 8 | 9 | ### Connection and Initialization 10 | 11 | - constructor() - Initializes API, sets host and base path 12 | - init() - Starts WebSocket connection for real-time updates 13 | - #createSocket() - Creates and manages WebSocket connection 14 | 15 | ### URL Management 16 | 17 | - internalURL(route) - Generates URL for internal endpoints 18 | - apiURL(route) - Generates URL for public API endpoints 19 | - fileURL(route) - Generates URL for static files 20 | - fetchApi(route, options) - Performs HTTP requests with automatic user headers 21 | 22 | ### Event Handling 23 | 24 | - addEventListener(type, callback) - Listens for API events (status, executing, progress, etc.) 25 | - removeEventListener(type, callback) - Removes event listeners 26 | - dispatchCustomEvent(type, detail) - Emits custom events 27 | 28 | ### Queue and Prompt Management 29 | 30 | - queuePrompt(number, data) - Adds prompt to execution queue 31 | - getQueue() - Gets current queue state (Running/Pending) 32 | - interrupt() - Interrupts currently executing prompt 33 | - clearItems(type) - Clears queue or history 34 | - deleteItem(type, id) - Removes item from queue or history 35 | 36 | ### History and Statistics 37 | 38 | - getHistory(max_items) - Gets history of executed prompts 39 | - getSystemStats() - Gets system statistics (Python, OS, GPU, etc.) 40 | - getLogs() - Gets system logs 41 | - getRawLogs() - Gets raw logs 42 | - subscribeLogs(enabled) - Enables/disables log subscription 43 | 44 | ### Model and Resource Management 45 | 46 | - getNodeDefs(options) - Gets definitions of available nodes 47 | - getExtensions() - List of installed extensions 48 | - getEmbeddings() - List of available embeddings 49 | - getModelFolders() - List of model folders 50 | - getModels(folder) - List of models in given folder 51 | - viewMetadata(folder, model) - Metadata of specific model 52 | 53 | ### Workflow Templates 54 | 55 | - getWorkflowTemplates() - Gets workflow templates from custom nodes 56 | - getCoreWorkflowTemplates() - Gets core workflow templates 57 | 58 | ### User Management 59 | 60 | - getUserConfig() - Gets user configuration 61 | - createUser(username) - Creates new user 62 | - getSettings() - Gets all user settings 63 | - getSetting(id) - Gets specific setting 64 | - storeSettings(settings) - Saves settings dictionary 65 | - storeSetting(id, value) - Saves single setting 66 | 67 | ### User Data 68 | 69 | - getUserData(file) - Gets user data file 70 | - storeUserData(file, data, options) - Saves user data 71 | - deleteUserData(file) - Deletes user data file 72 | - moveUserData(source, dest) - Moves data file 73 | - listUserDataFullInfo(dir) - Lists files with full information 74 | 75 | ### Other 76 | 77 | - getFolderPaths() - Gets system folder paths 78 | - getCustomNodesI18n() - Gets internationalization data for custom nodes 79 | 80 | ## Important Properties 81 | 82 | - clientId - Client ID from WebSocket 83 | - authToken - Authorization token for ComfyOrg account 84 | - apiKey - API key for ComfyOrg account 85 | - socket - Active WebSocket connection 86 | 87 | ## WebSocket Event Types 88 | 89 | - status - System status 90 | - executing - Currently executing node 91 | - progress - Execution progress 92 | - executed - Node executed 93 | - execution_start/success/error/interrupted/cached - Execution events 94 | - logs - System logs 95 | - b_preview - Image preview (binary) 96 | - reconnecting/reconnected - Connection events 97 | -------------------------------------------------------------------------------- /.github/workflows/clone.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Clone Count Update Everyday 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */24 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: gh login 16 | run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token 17 | 18 | - name: parse latest clone count 19 | run: | 20 | curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 21 | -H "Accept: application/vnd.github.v3+json" \ 22 | https://api.github.com/repos/${{ github.repository }}/traffic/clones \ 23 | > clone.json 24 | 25 | - name: create gist and download previous count 26 | id: set_id 27 | run: | 28 | if gh secret list | grep -q "GIST_ID" 29 | then 30 | echo "GIST_ID found" 31 | echo "GIST=${{ secrets.GIST_ID }}" >> $GITHUB_OUTPUT 32 | curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.GIST_ID }}/raw/clone.json > clone_before.json 33 | if cat clone_before.json | grep '404: Not Found'; then 34 | echo "GIST_ID not valid anymore. Creating another gist..." 35 | gist_id=$(gh gist create clone.json | awk -F / '{print $NF}') 36 | echo $gist_id | gh secret set GIST_ID 37 | echo "GIST=$gist_id" >> $GITHUB_OUTPUT 38 | cp clone.json clone_before.json 39 | git rm --ignore-unmatch CLONE.md 40 | fi 41 | else 42 | echo "GIST_ID not found. Creating a gist..." 43 | gist_id=$(gh gist create clone.json | awk -F / '{print $NF}') 44 | echo $gist_id | gh secret set GIST_ID 45 | echo "GIST=$gist_id" >> $GITHUB_OUTPUT 46 | cp clone.json clone_before.json 47 | fi 48 | 49 | - name: update clone.json 50 | run: | 51 | curl https://raw.githubusercontent.com/MShawon/github-clone-count-badge/master/main.py > main.py 52 | python3 main.py 53 | 54 | - name: Update gist with latest count 55 | run: | 56 | content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "clone.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g') 57 | echo '{"description": "${{ github.repository }} clone statistics", "files": {"clone.json": {"content": "'"$content"'"}}}' > post_clone.json 58 | curl -s -X PATCH \ 59 | --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 60 | -H "Content-Type: application/json" \ 61 | -d @post_clone.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1 62 | 63 | if [ ! -f CLONE.md ]; then 64 | shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=" 65 | url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/clone.json" 66 | repo="https://github.com/MShawon/github-clone-count-badge" 67 | echo ''> CLONE.md 68 | echo ' 69 | **Markdown** 70 | 71 | ```markdown' >> CLONE.md 72 | echo "[![GitHub Clones]($shields$url&logo=github)]($repo)" >> CLONE.md 73 | echo ' 74 | ``` 75 | 76 | **HTML** 77 | ```html' >> CLONE.md 78 | echo "GitHub Clones" >> CLONE.md 79 | echo '```' >> CLONE.md 80 | 81 | git add CLONE.md 82 | git config --global user.name "GitHub Action" 83 | git config --global user.email "action@github.com" 84 | git commit -m "create clone count badge" 85 | fi 86 | 87 | - name: Push 88 | uses: ad-m/github-push-action@master 89 | with: 90 | github_token: ${{ secrets.GITHUB_TOKEN }} 91 | -------------------------------------------------------------------------------- /js/css/blend_mode_menu.css: -------------------------------------------------------------------------------- 1 | /* Blend Mode Menu Styles */ 2 | #blend-mode-menu { 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | background: #2a2a2a; 7 | border: 1px solid #3a3a3a; 8 | border-radius: 4px; 9 | z-index: 10000; 10 | box-shadow: 0 2px 10px rgba(0,0,0,0.3); 11 | min-width: 200px; 12 | } 13 | 14 | #blend-mode-menu .blend-menu-title-bar { 15 | background: #3a3a3a; 16 | color: white; 17 | padding: 8px 10px; 18 | cursor: move; 19 | user-select: none; 20 | border-radius: 3px 3px 0 0; 21 | font-size: 12px; 22 | font-weight: bold; 23 | border-bottom: 1px solid #4a4a4a; 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | } 28 | 29 | #blend-mode-menu .blend-menu-title-text { 30 | flex: 1; 31 | cursor: move; 32 | white-space: nowrap; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | } 36 | 37 | #blend-mode-menu .blend-menu-close-button { 38 | background: none; 39 | border: none; 40 | color: white; 41 | font-size: 18px; 42 | cursor: pointer; 43 | padding: 0; 44 | margin: 0; 45 | width: 20px; 46 | height: 20px; 47 | display: flex; 48 | align-items: center; 49 | justify-content: center; 50 | border-radius: 3px; 51 | transition: background-color 0.2s; 52 | } 53 | 54 | #blend-mode-menu .blend-menu-close-button:hover { 55 | background-color: #4a4a4a; 56 | } 57 | 58 | #blend-mode-menu .blend-menu-close-button:focus { 59 | background-color: transparent; 60 | } 61 | 62 | #blend-mode-menu .blend-menu-content { 63 | padding: 5px; 64 | } 65 | 66 | #blend-mode-menu .blend-area-container { 67 | padding: 5px 10px; 68 | border-bottom: 1px solid #4a4a4a; 69 | } 70 | 71 | #blend-mode-menu .blend-area-label { 72 | color: white; 73 | display: block; 74 | margin-bottom: 5px; 75 | font-size: 12px; 76 | } 77 | 78 | #blend-mode-menu .blend-area-slider { 79 | width: 100%; 80 | margin: 5px 0; 81 | -webkit-appearance: none; 82 | height: 4px; 83 | background: #555; 84 | border-radius: 2px; 85 | outline: none; 86 | } 87 | 88 | #blend-mode-menu .blend-area-slider::-webkit-slider-thumb { 89 | -webkit-appearance: none; 90 | appearance: none; 91 | width: 14px; 92 | height: 14px; 93 | background: #e0e0e0; 94 | border-radius: 50%; 95 | cursor: pointer; 96 | border: 2px solid #555; 97 | transition: background 0.2s; 98 | } 99 | 100 | #blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover { 101 | background: #fff; 102 | } 103 | 104 | #blend-mode-menu .blend-area-slider::-moz-range-thumb { 105 | width: 14px; 106 | height: 14px; 107 | background: #e0e0e0; 108 | border-radius: 50%; 109 | cursor: pointer; 110 | border: 2px solid #555; 111 | } 112 | 113 | #blend-mode-menu .blend-mode-container { 114 | margin-bottom: 5px; 115 | } 116 | 117 | #blend-mode-menu .blend-mode-option { 118 | padding: 5px 10px; 119 | color: white; 120 | cursor: pointer; 121 | transition: background-color 0.2s; 122 | } 123 | 124 | #blend-mode-menu .blend-mode-option:hover { 125 | background-color: #3a3a3a; 126 | } 127 | 128 | #blend-mode-menu .blend-mode-option.active { 129 | background-color: #3a3a3a; 130 | } 131 | 132 | #blend-mode-menu .blend-opacity-slider { 133 | width: 100%; 134 | margin: 5px 0; 135 | display: none; 136 | -webkit-appearance: none; 137 | height: 4px; 138 | background: #555; 139 | border-radius: 2px; 140 | outline: none; 141 | } 142 | 143 | #blend-mode-menu .blend-mode-container.active .blend-opacity-slider { 144 | display: block; 145 | } 146 | 147 | #blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb { 148 | -webkit-appearance: none; 149 | appearance: none; 150 | width: 14px; 151 | height: 14px; 152 | background: #e0e0e0; 153 | border-radius: 50%; 154 | cursor: pointer; 155 | border: 2px solid #555; 156 | transition: background 0.2s; 157 | } 158 | 159 | #blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover { 160 | background: #fff; 161 | } 162 | 163 | #blend-mode-menu .blend-opacity-slider::-moz-range-thumb { 164 | width: 14px; 165 | height: 14px; 166 | background: #e0e0e0; 167 | border-radius: 50%; 168 | cursor: pointer; 169 | border: 2px solid #555; 170 | } 171 | -------------------------------------------------------------------------------- /src/css/blend_mode_menu.css: -------------------------------------------------------------------------------- 1 | /* Blend Mode Menu Styles */ 2 | #blend-mode-menu { 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | background: #2a2a2a; 7 | border: 1px solid #3a3a3a; 8 | border-radius: 4px; 9 | z-index: 10000; 10 | box-shadow: 0 2px 10px rgba(0,0,0,0.3); 11 | min-width: 200px; 12 | } 13 | 14 | #blend-mode-menu .blend-menu-title-bar { 15 | background: #3a3a3a; 16 | color: white; 17 | padding: 8px 10px; 18 | cursor: move; 19 | user-select: none; 20 | border-radius: 3px 3px 0 0; 21 | font-size: 12px; 22 | font-weight: bold; 23 | border-bottom: 1px solid #4a4a4a; 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | } 28 | 29 | #blend-mode-menu .blend-menu-title-text { 30 | flex: 1; 31 | cursor: move; 32 | white-space: nowrap; 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | } 36 | 37 | #blend-mode-menu .blend-menu-close-button { 38 | background: none; 39 | border: none; 40 | color: white; 41 | font-size: 18px; 42 | cursor: pointer; 43 | padding: 0; 44 | margin: 0; 45 | width: 20px; 46 | height: 20px; 47 | display: flex; 48 | align-items: center; 49 | justify-content: center; 50 | border-radius: 3px; 51 | transition: background-color 0.2s; 52 | } 53 | 54 | #blend-mode-menu .blend-menu-close-button:hover { 55 | background-color: #4a4a4a; 56 | } 57 | 58 | #blend-mode-menu .blend-menu-close-button:focus { 59 | background-color: transparent; 60 | } 61 | 62 | #blend-mode-menu .blend-menu-content { 63 | padding: 5px; 64 | } 65 | 66 | #blend-mode-menu .blend-area-container { 67 | padding: 5px 10px; 68 | border-bottom: 1px solid #4a4a4a; 69 | } 70 | 71 | #blend-mode-menu .blend-area-label { 72 | color: white; 73 | display: block; 74 | margin-bottom: 5px; 75 | font-size: 12px; 76 | } 77 | 78 | #blend-mode-menu .blend-area-slider { 79 | width: 100%; 80 | margin: 5px 0; 81 | -webkit-appearance: none; 82 | height: 4px; 83 | background: #555; 84 | border-radius: 2px; 85 | outline: none; 86 | } 87 | 88 | #blend-mode-menu .blend-area-slider::-webkit-slider-thumb { 89 | -webkit-appearance: none; 90 | appearance: none; 91 | width: 14px; 92 | height: 14px; 93 | background: #e0e0e0; 94 | border-radius: 50%; 95 | cursor: pointer; 96 | border: 2px solid #555; 97 | transition: background 0.2s; 98 | } 99 | 100 | #blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover { 101 | background: #fff; 102 | } 103 | 104 | #blend-mode-menu .blend-area-slider::-moz-range-thumb { 105 | width: 14px; 106 | height: 14px; 107 | background: #e0e0e0; 108 | border-radius: 50%; 109 | cursor: pointer; 110 | border: 2px solid #555; 111 | } 112 | 113 | #blend-mode-menu .blend-mode-container { 114 | margin-bottom: 5px; 115 | } 116 | 117 | #blend-mode-menu .blend-mode-option { 118 | padding: 5px 10px; 119 | color: white; 120 | cursor: pointer; 121 | transition: background-color 0.2s; 122 | } 123 | 124 | #blend-mode-menu .blend-mode-option:hover { 125 | background-color: #3a3a3a; 126 | } 127 | 128 | #blend-mode-menu .blend-mode-option.active { 129 | background-color: #3a3a3a; 130 | } 131 | 132 | #blend-mode-menu .blend-opacity-slider { 133 | width: 100%; 134 | margin: 5px 0; 135 | display: none; 136 | -webkit-appearance: none; 137 | height: 4px; 138 | background: #555; 139 | border-radius: 2px; 140 | outline: none; 141 | } 142 | 143 | #blend-mode-menu .blend-mode-container.active .blend-opacity-slider { 144 | display: block; 145 | } 146 | 147 | #blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb { 148 | -webkit-appearance: none; 149 | appearance: none; 150 | width: 14px; 151 | height: 14px; 152 | background: #e0e0e0; 153 | border-radius: 50%; 154 | cursor: pointer; 155 | border: 2px solid #555; 156 | transition: background 0.2s; 157 | } 158 | 159 | #blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover { 160 | background: #fff; 161 | } 162 | 163 | #blend-mode-menu .blend-opacity-slider::-moz-range-thumb { 164 | width: 14px; 165 | height: 14px; 166 | background: #e0e0e0; 167 | border-radius: 50%; 168 | cursor: pointer; 169 | border: 2px solid #555; 170 | } 171 | -------------------------------------------------------------------------------- /js/utils/ClipspaceUtils.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | // @ts-ignore 3 | import { ComfyApp } from "../../../scripts/app.js"; 4 | const log = createModuleLogger('ClipspaceUtils'); 5 | /** 6 | * Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors 7 | * @returns {boolean} - True if clipspace is valid and ready to use, false otherwise 8 | */ 9 | export function validateAndFixClipspace() { 10 | log.debug("Validating and fixing clipspace structure"); 11 | // Check if clipspace exists 12 | if (!ComfyApp.clipspace) { 13 | log.debug("ComfyUI clipspace is not available"); 14 | return false; 15 | } 16 | // Validate clipspace structure 17 | if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) { 18 | log.debug("ComfyUI clipspace has no images"); 19 | return false; 20 | } 21 | log.debug("Current clipspace state:", { 22 | hasImgs: !!ComfyApp.clipspace.imgs, 23 | imgsLength: ComfyApp.clipspace.imgs?.length, 24 | selectedIndex: ComfyApp.clipspace.selectedIndex, 25 | combinedIndex: ComfyApp.clipspace.combinedIndex, 26 | img_paste_mode: ComfyApp.clipspace.img_paste_mode 27 | }); 28 | // Ensure required indices are set 29 | if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) { 30 | ComfyApp.clipspace.selectedIndex = 0; 31 | log.debug("Fixed clipspace selectedIndex to 0"); 32 | } 33 | if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) { 34 | ComfyApp.clipspace.combinedIndex = 0; 35 | log.debug("Fixed clipspace combinedIndex to 0"); 36 | } 37 | if (!ComfyApp.clipspace.img_paste_mode) { 38 | ComfyApp.clipspace.img_paste_mode = 'selected'; 39 | log.debug("Fixed clipspace img_paste_mode to 'selected'"); 40 | } 41 | // Ensure indices are within bounds 42 | const maxIndex = ComfyApp.clipspace.imgs.length - 1; 43 | if (ComfyApp.clipspace.selectedIndex > maxIndex) { 44 | ComfyApp.clipspace.selectedIndex = maxIndex; 45 | log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`); 46 | } 47 | if (ComfyApp.clipspace.combinedIndex > maxIndex) { 48 | ComfyApp.clipspace.combinedIndex = maxIndex; 49 | log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`); 50 | } 51 | // Verify the image at combinedIndex exists and has src 52 | const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]; 53 | if (!combinedImg || !combinedImg.src) { 54 | log.debug("Image at combinedIndex is missing or has no src, trying to find valid image"); 55 | // Try to use the first available image 56 | for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) { 57 | if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) { 58 | ComfyApp.clipspace.combinedIndex = i; 59 | log.debug(`Fixed combinedIndex to ${i} (first valid image)`); 60 | break; 61 | } 62 | } 63 | // Final check - if still no valid image found 64 | const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]; 65 | if (!finalImg || !finalImg.src) { 66 | log.error("No valid images found in clipspace after attempting fixes"); 67 | return false; 68 | } 69 | } 70 | log.debug("Final clipspace structure:", { 71 | selectedIndex: ComfyApp.clipspace.selectedIndex, 72 | combinedIndex: ComfyApp.clipspace.combinedIndex, 73 | img_paste_mode: ComfyApp.clipspace.img_paste_mode, 74 | imgsLength: ComfyApp.clipspace.imgs?.length, 75 | combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...' 76 | }); 77 | return true; 78 | } 79 | /** 80 | * Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure 81 | * @param {any} node - The ComfyUI node to paste to 82 | * @returns {boolean} - True if paste was successful, false otherwise 83 | */ 84 | export function safeClipspacePaste(node) { 85 | log.debug("Attempting safe clipspace paste"); 86 | if (!validateAndFixClipspace()) { 87 | log.debug("Clipspace validation failed, cannot paste"); 88 | return false; 89 | } 90 | try { 91 | ComfyApp.pasteFromClipspace(node); 92 | log.debug("Successfully called pasteFromClipspace"); 93 | return true; 94 | } 95 | catch (error) { 96 | log.error("Error calling pasteFromClipspace:", error); 97 | return false; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/utils/ClipspaceUtils.ts: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | // @ts-ignore 3 | import { ComfyApp } from "../../../scripts/app.js"; 4 | 5 | const log = createModuleLogger('ClipspaceUtils'); 6 | 7 | /** 8 | * Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors 9 | * @returns {boolean} - True if clipspace is valid and ready to use, false otherwise 10 | */ 11 | export function validateAndFixClipspace(): boolean { 12 | log.debug("Validating and fixing clipspace structure"); 13 | 14 | // Check if clipspace exists 15 | if (!ComfyApp.clipspace) { 16 | log.debug("ComfyUI clipspace is not available"); 17 | return false; 18 | } 19 | 20 | // Validate clipspace structure 21 | if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) { 22 | log.debug("ComfyUI clipspace has no images"); 23 | return false; 24 | } 25 | 26 | log.debug("Current clipspace state:", { 27 | hasImgs: !!ComfyApp.clipspace.imgs, 28 | imgsLength: ComfyApp.clipspace.imgs?.length, 29 | selectedIndex: ComfyApp.clipspace.selectedIndex, 30 | combinedIndex: ComfyApp.clipspace.combinedIndex, 31 | img_paste_mode: ComfyApp.clipspace.img_paste_mode 32 | }); 33 | 34 | // Ensure required indices are set 35 | if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) { 36 | ComfyApp.clipspace.selectedIndex = 0; 37 | log.debug("Fixed clipspace selectedIndex to 0"); 38 | } 39 | 40 | if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) { 41 | ComfyApp.clipspace.combinedIndex = 0; 42 | log.debug("Fixed clipspace combinedIndex to 0"); 43 | } 44 | 45 | if (!ComfyApp.clipspace.img_paste_mode) { 46 | ComfyApp.clipspace.img_paste_mode = 'selected'; 47 | log.debug("Fixed clipspace img_paste_mode to 'selected'"); 48 | } 49 | 50 | // Ensure indices are within bounds 51 | const maxIndex = ComfyApp.clipspace.imgs.length - 1; 52 | if (ComfyApp.clipspace.selectedIndex > maxIndex) { 53 | ComfyApp.clipspace.selectedIndex = maxIndex; 54 | log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`); 55 | } 56 | if (ComfyApp.clipspace.combinedIndex > maxIndex) { 57 | ComfyApp.clipspace.combinedIndex = maxIndex; 58 | log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`); 59 | } 60 | 61 | // Verify the image at combinedIndex exists and has src 62 | const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]; 63 | if (!combinedImg || !combinedImg.src) { 64 | log.debug("Image at combinedIndex is missing or has no src, trying to find valid image"); 65 | // Try to use the first available image 66 | for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) { 67 | if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) { 68 | ComfyApp.clipspace.combinedIndex = i; 69 | log.debug(`Fixed combinedIndex to ${i} (first valid image)`); 70 | break; 71 | } 72 | } 73 | 74 | // Final check - if still no valid image found 75 | const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]; 76 | if (!finalImg || !finalImg.src) { 77 | log.error("No valid images found in clipspace after attempting fixes"); 78 | return false; 79 | } 80 | } 81 | 82 | log.debug("Final clipspace structure:", { 83 | selectedIndex: ComfyApp.clipspace.selectedIndex, 84 | combinedIndex: ComfyApp.clipspace.combinedIndex, 85 | img_paste_mode: ComfyApp.clipspace.img_paste_mode, 86 | imgsLength: ComfyApp.clipspace.imgs?.length, 87 | combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...' 88 | }); 89 | 90 | return true; 91 | } 92 | 93 | /** 94 | * Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure 95 | * @param {any} node - The ComfyUI node to paste to 96 | * @returns {boolean} - True if paste was successful, false otherwise 97 | */ 98 | export function safeClipspacePaste(node: any): boolean { 99 | log.debug("Attempting safe clipspace paste"); 100 | 101 | if (!validateAndFixClipspace()) { 102 | log.debug("Clipspace validation failed, cannot paste"); 103 | return false; 104 | } 105 | 106 | try { 107 | ComfyApp.pasteFromClipspace(node); 108 | log.debug("Successfully called pasteFromClipspace"); 109 | return true; 110 | } catch (error) { 111 | log.error("Error calling pasteFromClipspace:", error); 112 | return false; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /js/ShapeTool.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | const log = createModuleLogger('ShapeTool'); 3 | export class ShapeTool { 4 | constructor(canvas) { 5 | this.isActive = false; 6 | this.canvas = canvas; 7 | this.shape = { 8 | points: [], 9 | isClosed: false, 10 | }; 11 | } 12 | toggle() { 13 | this.isActive = !this.isActive; 14 | if (this.isActive) { 15 | log.info('ShapeTool activated. Press "S" to exit.'); 16 | this.reset(); 17 | } 18 | else { 19 | log.info('ShapeTool deactivated.'); 20 | this.reset(); 21 | } 22 | this.canvas.render(); 23 | } 24 | activate() { 25 | if (!this.isActive) { 26 | this.isActive = true; 27 | log.info('ShapeTool activated. Hold Shift+S to draw.'); 28 | this.reset(); 29 | this.canvas.render(); 30 | } 31 | } 32 | deactivate() { 33 | if (this.isActive) { 34 | this.isActive = false; 35 | log.info('ShapeTool deactivated.'); 36 | this.reset(); 37 | this.canvas.render(); 38 | } 39 | } 40 | addPoint(point) { 41 | if (this.shape.isClosed) { 42 | this.reset(); 43 | } 44 | // Check if the new point is close to the start point to close the shape 45 | if (this.shape.points.length > 2) { 46 | const firstPoint = this.shape.points[0]; 47 | const dx = point.x - firstPoint.x; 48 | const dy = point.y - firstPoint.y; 49 | if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) { 50 | this.closeShape(); 51 | return; 52 | } 53 | } 54 | this.shape.points.push(point); 55 | this.canvas.render(); 56 | } 57 | closeShape() { 58 | if (this.shape.points.length > 2) { 59 | this.shape.isClosed = true; 60 | log.info('Shape closed with', this.shape.points.length, 'points.'); 61 | this.canvas.defineOutputAreaWithShape(this.shape); 62 | this.reset(); 63 | } 64 | this.canvas.render(); 65 | } 66 | getBoundingBox() { 67 | if (this.shape.points.length === 0) { 68 | return null; 69 | } 70 | let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 71 | this.shape.points.forEach(p => { 72 | minX = Math.min(minX, p.x); 73 | minY = Math.min(minY, p.y); 74 | maxX = Math.max(maxX, p.x); 75 | maxY = Math.max(maxY, p.y); 76 | }); 77 | return { 78 | x: minX, 79 | y: minY, 80 | width: maxX - minX, 81 | height: maxY - minY, 82 | }; 83 | } 84 | reset() { 85 | this.shape = { 86 | points: [], 87 | isClosed: false, 88 | }; 89 | log.info('ShapeTool reset.'); 90 | this.canvas.render(); 91 | } 92 | render(ctx) { 93 | if (this.shape.points.length === 0) { 94 | return; 95 | } 96 | ctx.save(); 97 | ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; 98 | ctx.lineWidth = 2 / this.canvas.viewport.zoom; 99 | ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); 100 | ctx.beginPath(); 101 | const startPoint = this.shape.points[0]; 102 | ctx.moveTo(startPoint.x, startPoint.y); 103 | for (let i = 1; i < this.shape.points.length; i++) { 104 | ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y); 105 | } 106 | if (this.shape.isClosed) { 107 | ctx.closePath(); 108 | ctx.fillStyle = 'rgba(0, 255, 255, 0.2)'; 109 | ctx.fill(); 110 | } 111 | else if (this.isActive) { 112 | // Draw a line to the current mouse position 113 | ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y); 114 | } 115 | ctx.stroke(); 116 | // Draw vertices 117 | const mouse = this.canvas.lastMousePosition; 118 | const firstPoint = this.shape.points[0]; 119 | let highlightFirst = false; 120 | if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) { 121 | const dx = mouse.x - firstPoint.x; 122 | const dy = mouse.y - firstPoint.y; 123 | const dist = Math.sqrt(dx * dx + dy * dy); 124 | if (dist < 10 / this.canvas.viewport.zoom) { 125 | highlightFirst = true; 126 | } 127 | } 128 | this.shape.points.forEach((point, index) => { 129 | ctx.beginPath(); 130 | if (index === 0 && highlightFirst) { 131 | ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI); 132 | ctx.fillStyle = 'yellow'; 133 | } 134 | else { 135 | ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI); 136 | ctx.fillStyle = 'rgba(0, 255, 255, 1)'; 137 | } 138 | ctx.fill(); 139 | }); 140 | ctx.restore(); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: 'Report something that is not working correctly' 3 | title: "[BUG] " 4 | labels: [bug] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Prerequisites 9 | options: 10 | - label: I am running the latest version of [ComfyUI](https://github.com/comfyanonymous/ComfyUI/releases) 11 | required: true 12 | - label: I am running the latest version of [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend/releases) 13 | required: true 14 | - label: I am running the latest version of LayerForge [Github](https://github.com/Azornes/Comfyui-LayerForge/releases) | [Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge) 15 | required: true 16 | - label: I have searched existing(open/closed) issues to make sure this isn't a duplicate 17 | required: true 18 | 19 | - type: textarea 20 | id: description 21 | attributes: 22 | label: What happened? 23 | description: A clear and concise description of the bug. Include screenshots or videos if helpful. 24 | placeholder: | 25 | Example: "When I connect a image to an Input, the connection line appears but the workflow fails to execute with an error message..." 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: reproduce 31 | attributes: 32 | label: Steps to Reproduce 33 | description: How can I reproduce this issue? Please attach your workflow (JSON or PNG) if needed. 34 | placeholder: | 35 | 1. Connect Image to Input 36 | 2. Click Queue Prompt 37 | 3. See error 38 | validations: 39 | required: true 40 | 41 | - type: dropdown 42 | id: severity 43 | attributes: 44 | label: How is this affecting you? 45 | options: 46 | - Crashes ComfyUI completely 47 | - Workflow won't execute 48 | - Feature doesn't work as expected 49 | - Visual/UI issue only 50 | - Minor inconvenience 51 | validations: 52 | required: true 53 | 54 | - type: dropdown 55 | id: browser 56 | attributes: 57 | label: Browser 58 | description: Which browser are you using? 59 | options: 60 | - Chrome/Chromium 61 | - Firefox 62 | - Safari 63 | - Edge 64 | - Other 65 | validations: 66 | required: true 67 | 68 | - type: markdown 69 | attributes: 70 | value: | 71 | ## Additional Information (Optional) 72 | *The following fields help me debug complex issues but are not required for most bug reports.* 73 | ### 🔍 Enable Debug Logs (for **full** logs): 74 | 75 | #### 1. Edit `config.js` (Frontend Logs): 76 | Path: 77 | ``` 78 | ComfyUI/custom_nodes/Comfyui-LayerForge/js/config.js 79 | ``` 80 | Find: 81 | ```js 82 | export const LOG_LEVEL = 'NONE'; 83 | ``` 84 | Change to: 85 | ```js 86 | export const LOG_LEVEL = 'DEBUG'; 87 | ``` 88 | 89 | #### 2. Edit `config.py` (Backend Logs): 90 | Path: 91 | ``` 92 | ComfyUI/custom_nodes/Comfyui-LayerForge/python/config.py 93 | ``` 94 | Find: 95 | ```python 96 | LOG_LEVEL = 'NONE' 97 | ``` 98 | Change to: 99 | ```python 100 | LOG_LEVEL = 'DEBUG' 101 | ``` 102 | 103 | ➡️ **Restart ComfyUI** after applying these changes to activate full logging. 104 | - type: textarea 105 | id: console-errors 106 | attributes: 107 | label: Console Errors 108 | description: | 109 | If you see red error messages in the browser console (F12), paste them here 110 | More info: 111 | After enabling DEBUG logs: 112 | 1. Open Developer Tools → Console. 113 | - Chrome/Edge (Win/Linux): `Ctrl+Shift+J` 114 | Mac: `Cmd+Option+J` 115 | - Firefox (Win/Linux): `Ctrl+Shift+K` 116 | Mac: `Cmd+Option+K` 117 | - Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C` 118 | 2. Clear console (before reproducing): 119 | - Chrome/Edge: “🚫 Clear console” or `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac). 120 | - Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux) / `Cmd+K` (Mac). 121 | - Safari: 🗑 icon or `Cmd+K`. 122 | 3. Reproduce the issue. 123 | 4. Copy-paste the **TEXT** logs here (no screenshots). 124 | render: javascript 125 | 126 | - type: textarea 127 | id: logs 128 | attributes: 129 | label: Logs 130 | description: | 131 | If relevant, paste any terminal/server logs here 132 | More info: 133 | After enabling DEBUG logs, please: 134 | 1. Restart ComfyUI. 135 | 2. Reproduce the issue. 136 | 3. Copy-paste the newest **TEXT** logs from the terminal/console here. 137 | render: shell 138 | 139 | - type: textarea 140 | id: additional 141 | attributes: 142 | label: Additional Context, Environment (OS, ComfyUI versions, Comfyui-LayerForge version) 143 | description: Any other information that might help (OS, GPU, specific nodes involved, etc.) 144 | 145 | 146 | -------------------------------------------------------------------------------- /js/utils/ImageUploadUtils.js: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { api } from "../../../scripts/api.js"; 3 | import { createModuleLogger } from "./LoggerUtils.js"; 4 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 5 | const log = createModuleLogger('ImageUploadUtils'); 6 | /** 7 | * Uploads an image blob to ComfyUI server and returns image element 8 | * @param blob - Image blob to upload 9 | * @param options - Upload options 10 | * @returns Promise with upload result 11 | */ 12 | export const uploadImageBlob = withErrorHandling(async function (blob, options = {}) { 13 | if (!blob) { 14 | throw createValidationError("Blob is required", { blob }); 15 | } 16 | if (blob.size === 0) { 17 | throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); 18 | } 19 | const { filenamePrefix = 'layerforge', overwrite = true, type = 'temp', nodeId } = options; 20 | // Generate unique filename 21 | const timestamp = Date.now(); 22 | const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; 23 | const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; 24 | log.debug('Uploading image blob:', { 25 | filename, 26 | blobSize: blob.size, 27 | type, 28 | overwrite 29 | }); 30 | // Create FormData 31 | const formData = new FormData(); 32 | formData.append("image", blob, filename); 33 | formData.append("overwrite", overwrite.toString()); 34 | formData.append("type", type); 35 | // Upload to server 36 | const response = await api.fetchApi("/upload/image", { 37 | method: "POST", 38 | body: formData, 39 | }); 40 | if (!response.ok) { 41 | throw createNetworkError(`Failed to upload image: ${response.statusText}`, { 42 | status: response.status, 43 | statusText: response.statusText, 44 | filename, 45 | blobSize: blob.size 46 | }); 47 | } 48 | const data = await response.json(); 49 | log.debug('Image uploaded successfully:', data); 50 | // Create image element with proper URL 51 | const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); 52 | const imageElement = new Image(); 53 | imageElement.crossOrigin = "anonymous"; 54 | // Wait for image to load 55 | await new Promise((resolve, reject) => { 56 | imageElement.onload = () => { 57 | log.debug("Uploaded image loaded successfully", { 58 | width: imageElement.width, 59 | height: imageElement.height, 60 | src: imageElement.src.substring(0, 100) + '...' 61 | }); 62 | resolve(); 63 | }; 64 | imageElement.onerror = (error) => { 65 | log.error("Failed to load uploaded image", error); 66 | reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename })); 67 | }; 68 | imageElement.src = imageUrl; 69 | }); 70 | return { 71 | data, 72 | filename, 73 | imageUrl, 74 | imageElement 75 | }; 76 | }, 'uploadImageBlob'); 77 | /** 78 | * Uploads canvas content as image blob 79 | * @param canvas - Canvas element or Canvas object with canvasLayers 80 | * @param options - Upload options 81 | * @returns Promise with upload result 82 | */ 83 | export const uploadCanvasAsImage = withErrorHandling(async function (canvas, options = {}) { 84 | if (!canvas) { 85 | throw createValidationError("Canvas is required", { canvas }); 86 | } 87 | let blob = null; 88 | // Handle different canvas types 89 | if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { 90 | // LayerForge Canvas object 91 | blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); 92 | } 93 | else if (canvas instanceof HTMLCanvasElement) { 94 | // Standard HTML Canvas 95 | blob = await new Promise(resolve => canvas.toBlob(resolve)); 96 | } 97 | else { 98 | throw createValidationError("Unsupported canvas type", { 99 | canvas, 100 | hasCanvasLayers: !!canvas.canvasLayers, 101 | isHTMLCanvas: canvas instanceof HTMLCanvasElement 102 | }); 103 | } 104 | if (!blob) { 105 | throw createValidationError("Failed to generate canvas blob", { canvas, options }); 106 | } 107 | return uploadImageBlob(blob, options); 108 | }, 'uploadCanvasAsImage'); 109 | /** 110 | * Uploads canvas with mask as image blob 111 | * @param canvas - Canvas object with canvasLayers 112 | * @param options - Upload options 113 | * @returns Promise with upload result 114 | */ 115 | export const uploadCanvasWithMaskAsImage = withErrorHandling(async function (canvas, options = {}) { 116 | if (!canvas) { 117 | throw createValidationError("Canvas is required", { canvas }); 118 | } 119 | if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') { 120 | throw createValidationError("Canvas does not support mask operations", { 121 | canvas, 122 | hasCanvasLayers: !!canvas.canvasLayers, 123 | hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') 124 | }); 125 | } 126 | const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); 127 | if (!blob) { 128 | throw createValidationError("Failed to generate canvas with mask blob", { canvas, options }); 129 | } 130 | return uploadImageBlob(blob, options); 131 | }, 'uploadCanvasWithMaskAsImage'); 132 | -------------------------------------------------------------------------------- /src/ShapeTool.ts: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | import type { Canvas } from './Canvas.js'; 3 | import type { Point, Layer } from './types.js'; 4 | 5 | const log = createModuleLogger('ShapeTool'); 6 | 7 | interface Shape { 8 | points: Point[]; 9 | isClosed: boolean; 10 | } 11 | 12 | export class ShapeTool { 13 | private canvas: Canvas; 14 | public shape: Shape; 15 | public isActive: boolean = false; 16 | 17 | constructor(canvas: Canvas) { 18 | this.canvas = canvas; 19 | this.shape = { 20 | points: [], 21 | isClosed: false, 22 | }; 23 | } 24 | 25 | toggle() { 26 | this.isActive = !this.isActive; 27 | if (this.isActive) { 28 | log.info('ShapeTool activated. Press "S" to exit.'); 29 | this.reset(); 30 | } else { 31 | log.info('ShapeTool deactivated.'); 32 | this.reset(); 33 | } 34 | this.canvas.render(); 35 | } 36 | 37 | activate() { 38 | if (!this.isActive) { 39 | this.isActive = true; 40 | log.info('ShapeTool activated. Hold Shift+S to draw.'); 41 | this.reset(); 42 | this.canvas.render(); 43 | } 44 | } 45 | 46 | deactivate() { 47 | if (this.isActive) { 48 | this.isActive = false; 49 | log.info('ShapeTool deactivated.'); 50 | this.reset(); 51 | this.canvas.render(); 52 | } 53 | } 54 | 55 | addPoint(point: Point) { 56 | if (this.shape.isClosed) { 57 | this.reset(); 58 | } 59 | 60 | // Check if the new point is close to the start point to close the shape 61 | if (this.shape.points.length > 2) { 62 | const firstPoint = this.shape.points[0]; 63 | const dx = point.x - firstPoint.x; 64 | const dy = point.y - firstPoint.y; 65 | if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) { 66 | this.closeShape(); 67 | return; 68 | } 69 | } 70 | 71 | this.shape.points.push(point); 72 | this.canvas.render(); 73 | } 74 | 75 | closeShape() { 76 | if (this.shape.points.length > 2) { 77 | this.shape.isClosed = true; 78 | log.info('Shape closed with', this.shape.points.length, 'points.'); 79 | 80 | this.canvas.defineOutputAreaWithShape(this.shape); 81 | this.reset(); 82 | } 83 | this.canvas.render(); 84 | } 85 | 86 | getBoundingBox(): { x: number, y: number, width: number, height: number } | null { 87 | if (this.shape.points.length === 0) { 88 | return null; 89 | } 90 | 91 | let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 92 | this.shape.points.forEach(p => { 93 | minX = Math.min(minX, p.x); 94 | minY = Math.min(minY, p.y); 95 | maxX = Math.max(maxX, p.x); 96 | maxY = Math.max(maxY, p.y); 97 | }); 98 | 99 | return { 100 | x: minX, 101 | y: minY, 102 | width: maxX - minX, 103 | height: maxY - minY, 104 | }; 105 | } 106 | 107 | reset() { 108 | this.shape = { 109 | points: [], 110 | isClosed: false, 111 | }; 112 | log.info('ShapeTool reset.'); 113 | this.canvas.render(); 114 | } 115 | 116 | render(ctx: CanvasRenderingContext2D) { 117 | if (this.shape.points.length === 0) { 118 | return; 119 | } 120 | 121 | ctx.save(); 122 | ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; 123 | ctx.lineWidth = 2 / this.canvas.viewport.zoom; 124 | ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); 125 | 126 | ctx.beginPath(); 127 | const startPoint = this.shape.points[0]; 128 | ctx.moveTo(startPoint.x, startPoint.y); 129 | 130 | for (let i = 1; i < this.shape.points.length; i++) { 131 | ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y); 132 | } 133 | 134 | if (this.shape.isClosed) { 135 | ctx.closePath(); 136 | ctx.fillStyle = 'rgba(0, 255, 255, 0.2)'; 137 | ctx.fill(); 138 | } else if (this.isActive) { 139 | // Draw a line to the current mouse position 140 | ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y); 141 | } 142 | 143 | ctx.stroke(); 144 | 145 | // Draw vertices 146 | const mouse = this.canvas.lastMousePosition; 147 | const firstPoint = this.shape.points[0]; 148 | let highlightFirst = false; 149 | if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) { 150 | const dx = mouse.x - firstPoint.x; 151 | const dy = mouse.y - firstPoint.y; 152 | const dist = Math.sqrt(dx * dx + dy * dy); 153 | if (dist < 10 / this.canvas.viewport.zoom) { 154 | highlightFirst = true; 155 | } 156 | } 157 | 158 | this.shape.points.forEach((point, index) => { 159 | ctx.beginPath(); 160 | if (index === 0 && highlightFirst) { 161 | ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI); 162 | ctx.fillStyle = 'yellow'; 163 | } else { 164 | ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI); 165 | ctx.fillStyle = 'rgba(0, 255, 255, 1)'; 166 | } 167 | ctx.fill(); 168 | }); 169 | 170 | ctx.restore(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Canvas as CanvasClass } from './Canvas'; 2 | import type { CanvasLayers } from './CanvasLayers'; 3 | 4 | export interface ComfyWidget { 5 | name: string; 6 | type: string; 7 | value: any; 8 | callback?: (value: any) => void; 9 | options?: any; 10 | } 11 | 12 | export interface Layer { 13 | id: string; 14 | image: HTMLImageElement; 15 | imageId: string; 16 | name: string; 17 | x: number; 18 | y: number; 19 | width: number; 20 | height: number; 21 | originalWidth: number; 22 | originalHeight: number; 23 | rotation: number; 24 | zIndex: number; 25 | blendMode: string; 26 | opacity: number; 27 | visible: boolean; 28 | mask?: Float32Array; 29 | flipH?: boolean; 30 | flipV?: boolean; 31 | blendArea?: number; 32 | cropMode?: boolean; // czy warstwa jest w trybie crop 33 | cropBounds?: { // granice przycinania 34 | x: number; // offset od lewej krawędzi obrazu 35 | y: number; // offset od górnej krawędzi obrazu 36 | width: number; // szerokość widocznego obszaru 37 | height: number; // wysokość widocznego obszaru 38 | }; 39 | } 40 | 41 | export interface ComfyNode { 42 | id: number; 43 | type: string; 44 | widgets: ComfyWidget[]; 45 | imgs?: HTMLImageElement[]; 46 | size?: [number, number]; 47 | onResize?: () => void; 48 | setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void; 49 | graph?: any; 50 | onRemoved?: () => void; 51 | addDOMWidget?: (name: string, type: string, element: HTMLElement) => void; 52 | inputs?: Array<{ link: any }>; 53 | } 54 | 55 | declare global { 56 | interface Window { 57 | MaskEditorDialog?: { 58 | instance?: { 59 | getMessageBroker: () => any; 60 | }; 61 | }; 62 | } 63 | 64 | interface HTMLElement { 65 | getContext?(contextId: '2d', options?: any): CanvasRenderingContext2D | null; 66 | width: number; 67 | height: number; 68 | } 69 | } 70 | 71 | export interface Canvas { 72 | layers: Layer[]; 73 | selectedLayer: Layer | null; 74 | canvasSelection: any; 75 | lastMousePosition: Point; 76 | width: number; 77 | height: number; 78 | node: ComfyNode; 79 | viewport: { x: number, y: number, zoom: number }; 80 | canvas: HTMLCanvasElement; 81 | offscreenCanvas: HTMLCanvasElement; 82 | isMouseOver: boolean; 83 | maskTool: any; 84 | canvasLayersPanel: any; 85 | canvasState: any; 86 | widget?: { value: string }; 87 | imageReferenceManager: any; 88 | imageCache: any; 89 | dataInitialized: boolean; 90 | pendingDataCheck: number | null; 91 | pendingInputDataCheck: number | null; 92 | pendingBatchContext: any; 93 | canvasLayers: any; 94 | inputDataLoaded: boolean; 95 | lastLoadedLinkId: any; 96 | lastLoadedMaskLinkId: any; 97 | lastLoadedImageSrc?: string; 98 | outputAreaBounds: OutputAreaBounds; 99 | saveState: () => void; 100 | render: () => void; 101 | updateSelection: (layers: Layer[]) => void; 102 | requestSaveState: (immediate?: boolean) => void; 103 | saveToServer: (fileName: string) => Promise; 104 | removeLayersByIds: (ids: string[]) => void; 105 | batchPreviewManagers: any[]; 106 | getMouseWorldCoordinates: (e: MouseEvent) => Point; 107 | getMouseViewCoordinates: (e: MouseEvent) => Point; 108 | updateOutputAreaSize: (width: number, height: number) => void; 109 | undo: () => void; 110 | redo: () => void; 111 | } 112 | 113 | // A simplified interface for the Canvas class, containing only what ClipboardManager needs. 114 | export interface CanvasForClipboard { 115 | canvasLayers: CanvasLayersForClipboard; 116 | node: ComfyNode; 117 | } 118 | 119 | // A simplified interface for the CanvasLayers class. 120 | export interface CanvasLayersForClipboard { 121 | internalClipboard: Layer[]; 122 | pasteLayers(): void; 123 | addLayerWithImage(image: HTMLImageElement, layerProps: Partial, addMode: string): Promise; 124 | } 125 | 126 | export type AddMode = 'mouse' | 'fit' | 'center' | 'default'; 127 | 128 | export type ClipboardPreference = 'system' | 'clipspace'; 129 | 130 | export interface WebSocketMessage { 131 | type: string; 132 | nodeId?: string; 133 | [key: string]: any; 134 | } 135 | 136 | export interface AckCallback { 137 | resolve: (value: WebSocketMessage | PromiseLike) => void; 138 | reject: (reason?: any) => void; 139 | } 140 | 141 | export type AckCallbacks = Map; 142 | 143 | export interface CanvasState { 144 | layersUndoStack: Layer[][]; 145 | layersRedoStack: Layer[][]; 146 | maskUndoStack: HTMLCanvasElement[]; 147 | maskRedoStack: HTMLCanvasElement[]; 148 | saveMaskState(): void; 149 | } 150 | 151 | export interface Point { 152 | x: number; 153 | y: number; 154 | } 155 | 156 | export interface Shape { 157 | points: Point[]; 158 | isClosed: boolean; 159 | } 160 | 161 | export interface OutputAreaBounds { 162 | x: number; // Pozycja w świecie (może być ujemna) 163 | y: number; // Pozycja w świecie (może być ujemna) 164 | width: number; // Szerokość output area 165 | height: number; // Wysokość output area 166 | } 167 | 168 | export interface Viewport { 169 | x: number; 170 | y: number; 171 | zoom: number; 172 | } 173 | 174 | export interface Tensor { 175 | data: Float32Array; 176 | shape: number[]; 177 | width: number; 178 | height: number; 179 | } 180 | 181 | export interface ImageDataPixel { 182 | data: Uint8ClampedArray; 183 | width: number; 184 | height: number; 185 | } 186 | -------------------------------------------------------------------------------- /js/css/custom_shape_menu.css: -------------------------------------------------------------------------------- 1 | #layerforge-custom-shape-menu { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | background-color: #2f2f2f; 6 | color: #e0e0e0; 7 | padding: 12px; 8 | border-radius: 8px; 9 | box-shadow: 0 5px 15px rgba(0,0,0,0.3); 10 | display: none; 11 | flex-direction: column; 12 | gap: 10px; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 14 | font-size: 13px; 15 | z-index: 1001; 16 | border: 1px solid #202020; 17 | user-select: none; 18 | min-width: 220px; 19 | } 20 | 21 | #layerforge-custom-shape-menu .menu-line { 22 | font-weight: 600; 23 | color: #4a90e2; 24 | padding-bottom: 5px; 25 | border-bottom: 1px solid #444; 26 | margin-bottom: 5px; 27 | display: flex; 28 | align-items: center; 29 | gap: 8px; 30 | } 31 | 32 | /* --- MINIMIZED BAR INTERACTIVE STYLE --- */ 33 | .custom-shape-minimized-bar { 34 | font-size: 13px; 35 | font-weight: 600; 36 | padding: 6px 12px; 37 | border-radius: 6px; 38 | background: #222; 39 | color: #4a90e2; 40 | box-shadow: 0 2px 8px rgba(0,0,0,0.10); 41 | margin: 0 0 8px 0; 42 | user-select: none; 43 | cursor: pointer; 44 | border: 1px solid #444; 45 | transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s; 46 | outline: none; 47 | text-shadow: none; 48 | display: flex; 49 | align-items: center; 50 | gap: 8px; 51 | } 52 | .custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus { 53 | background: #2a2a2a; 54 | color: #4a90e2; 55 | border: 1.5px solid #4a90e2; 56 | box-shadow: 0 4px 16px #4a90e244; 57 | } 58 | 59 | #layerforge-custom-shape-menu .feature-container { 60 | background-color: #3a3a3a; 61 | border-radius: 6px; 62 | padding: 10px 12px; 63 | border: 1px solid #4a4a4a; 64 | margin-bottom: 12px; 65 | display: flex; 66 | flex-direction: column; 67 | gap: 10px; 68 | } 69 | #layerforge-custom-shape-menu .feature-container:last-child { 70 | margin-bottom: 0; 71 | } 72 | 73 | #layerforge-custom-shape-menu .slider-container { 74 | margin-top: 6px; 75 | margin-bottom: 0; 76 | display: none; 77 | gap: 6px; 78 | } 79 | 80 | #layerforge-custom-shape-menu .slider-label { 81 | font-size: 12px; 82 | margin-bottom: 6px; 83 | color: #e0e0e0; 84 | } 85 | 86 | #layerforge-custom-shape-menu input[type="range"] { 87 | -webkit-appearance: none; 88 | width: 100%; 89 | height: 4px; 90 | background: #555; 91 | border-radius: 2px; 92 | outline: none; 93 | padding: 0; 94 | margin: 0; 95 | } 96 | #layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb { 97 | -webkit-appearance: none; 98 | appearance: none; 99 | width: 14px; 100 | height: 14px; 101 | background: #e0e0e0; 102 | border-radius: 50%; 103 | cursor: pointer; 104 | border: 2px solid #555; 105 | transition: background 0.2s; 106 | } 107 | #layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover { 108 | background: #fff; 109 | } 110 | #layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb { 111 | width: 14px; 112 | height: 14px; 113 | background: #e0e0e0; 114 | border-radius: 50%; 115 | cursor: pointer; 116 | border: 2px solid #555; 117 | } 118 | 119 | #layerforge-custom-shape-menu .slider-value-display { 120 | font-size: 11px; 121 | text-align: center; 122 | margin-top: 4px; 123 | color: #bbb; 124 | min-height: 14px; 125 | } 126 | 127 | #layerforge-custom-shape-menu .extension-slider-container { 128 | margin: 10px 0; 129 | } 130 | 131 | #layerforge-custom-shape-menu .checkbox-container { 132 | display: flex; 133 | align-items: center; 134 | gap: 8px; 135 | padding: 5px 0; 136 | border-radius: 5px; 137 | cursor: pointer; 138 | transition: background-color 0.2s; 139 | position: relative; 140 | } 141 | 142 | #layerforge-custom-shape-menu .checkbox-container:hover { 143 | background-color: #4a4a4a; 144 | } 145 | 146 | #layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] { 147 | position: absolute; 148 | opacity: 0; 149 | cursor: pointer; 150 | height: 0; 151 | width: 0; 152 | } 153 | 154 | #layerforge-custom-shape-menu .checkbox-container .custom-checkbox { 155 | height: 16px; 156 | width: 16px; 157 | background-color: #2a2a2a; 158 | border: 1px solid #666; 159 | border-radius: 3px; 160 | transition: all 0.2s; 161 | position: relative; 162 | flex-shrink: 0; 163 | } 164 | 165 | #layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox { 166 | background-color: #3a76d6; 167 | border-color: #3a76d6; 168 | } 169 | 170 | #layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after { 171 | content: ""; 172 | position: absolute; 173 | display: none; 174 | left: 5px; 175 | top: 1px; 176 | width: 4px; 177 | height: 9px; 178 | border: solid white; 179 | border-width: 0 2px 2px 0; 180 | transform: rotate(45deg); 181 | } 182 | 183 | #layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after { 184 | display: block; 185 | } 186 | 187 | .layerforge-tooltip { 188 | position: fixed; 189 | background-color: #2f2f2f; 190 | color: #e0e0e0; 191 | padding: 8px 12px; 192 | border-radius: 6px; 193 | font-size: 12px; 194 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 195 | line-height: 1.4; 196 | max-width: 250px; 197 | word-wrap: break-word; 198 | box-shadow: 0 4px 12px rgba(0,0,0,0.4); 199 | border: 1px solid #202020; 200 | z-index: 10000; 201 | pointer-events: none; 202 | opacity: 0; 203 | transition: opacity 0.2s ease-in-out; 204 | } 205 | -------------------------------------------------------------------------------- /src/css/custom_shape_menu.css: -------------------------------------------------------------------------------- 1 | #layerforge-custom-shape-menu { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | background-color: #2f2f2f; 6 | color: #e0e0e0; 7 | padding: 12px; 8 | border-radius: 8px; 9 | box-shadow: 0 5px 15px rgba(0,0,0,0.3); 10 | display: none; 11 | flex-direction: column; 12 | gap: 10px; 13 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 14 | font-size: 13px; 15 | z-index: 1001; 16 | border: 1px solid #202020; 17 | user-select: none; 18 | min-width: 220px; 19 | } 20 | 21 | #layerforge-custom-shape-menu .menu-line { 22 | font-weight: 600; 23 | color: #4a90e2; 24 | padding-bottom: 5px; 25 | border-bottom: 1px solid #444; 26 | margin-bottom: 5px; 27 | display: flex; 28 | align-items: center; 29 | gap: 8px; 30 | } 31 | 32 | /* --- MINIMIZED BAR INTERACTIVE STYLE --- */ 33 | .custom-shape-minimized-bar { 34 | font-size: 13px; 35 | font-weight: 600; 36 | padding: 6px 12px; 37 | border-radius: 6px; 38 | background: #222; 39 | color: #4a90e2; 40 | box-shadow: 0 2px 8px rgba(0,0,0,0.10); 41 | margin: 0 0 8px 0; 42 | user-select: none; 43 | cursor: pointer; 44 | border: 1px solid #444; 45 | transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s; 46 | outline: none; 47 | text-shadow: none; 48 | display: flex; 49 | align-items: center; 50 | gap: 8px; 51 | } 52 | .custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus { 53 | background: #2a2a2a; 54 | color: #4a90e2; 55 | border: 1.5px solid #4a90e2; 56 | box-shadow: 0 4px 16px #4a90e244; 57 | } 58 | 59 | #layerforge-custom-shape-menu .feature-container { 60 | background-color: #3a3a3a; 61 | border-radius: 6px; 62 | padding: 10px 12px; 63 | border: 1px solid #4a4a4a; 64 | margin-bottom: 12px; 65 | display: flex; 66 | flex-direction: column; 67 | gap: 10px; 68 | } 69 | #layerforge-custom-shape-menu .feature-container:last-child { 70 | margin-bottom: 0; 71 | } 72 | 73 | #layerforge-custom-shape-menu .slider-container { 74 | margin-top: 6px; 75 | margin-bottom: 0; 76 | display: none; 77 | gap: 6px; 78 | } 79 | 80 | #layerforge-custom-shape-menu .slider-label { 81 | font-size: 12px; 82 | margin-bottom: 6px; 83 | color: #e0e0e0; 84 | } 85 | 86 | #layerforge-custom-shape-menu input[type="range"] { 87 | -webkit-appearance: none; 88 | width: 100%; 89 | height: 4px; 90 | background: #555; 91 | border-radius: 2px; 92 | outline: none; 93 | padding: 0; 94 | margin: 0; 95 | } 96 | #layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb { 97 | -webkit-appearance: none; 98 | appearance: none; 99 | width: 14px; 100 | height: 14px; 101 | background: #e0e0e0; 102 | border-radius: 50%; 103 | cursor: pointer; 104 | border: 2px solid #555; 105 | transition: background 0.2s; 106 | } 107 | #layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover { 108 | background: #fff; 109 | } 110 | #layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb { 111 | width: 14px; 112 | height: 14px; 113 | background: #e0e0e0; 114 | border-radius: 50%; 115 | cursor: pointer; 116 | border: 2px solid #555; 117 | } 118 | 119 | #layerforge-custom-shape-menu .slider-value-display { 120 | font-size: 11px; 121 | text-align: center; 122 | margin-top: 4px; 123 | color: #bbb; 124 | min-height: 14px; 125 | } 126 | 127 | #layerforge-custom-shape-menu .extension-slider-container { 128 | margin: 10px 0; 129 | } 130 | 131 | #layerforge-custom-shape-menu .checkbox-container { 132 | display: flex; 133 | align-items: center; 134 | gap: 8px; 135 | padding: 5px 0; 136 | border-radius: 5px; 137 | cursor: pointer; 138 | transition: background-color 0.2s; 139 | position: relative; 140 | } 141 | 142 | #layerforge-custom-shape-menu .checkbox-container:hover { 143 | background-color: #4a4a4a; 144 | } 145 | 146 | #layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] { 147 | position: absolute; 148 | opacity: 0; 149 | cursor: pointer; 150 | height: 0; 151 | width: 0; 152 | } 153 | 154 | #layerforge-custom-shape-menu .checkbox-container .custom-checkbox { 155 | height: 16px; 156 | width: 16px; 157 | background-color: #2a2a2a; 158 | border: 1px solid #666; 159 | border-radius: 3px; 160 | transition: all 0.2s; 161 | position: relative; 162 | flex-shrink: 0; 163 | } 164 | 165 | #layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox { 166 | background-color: #3a76d6; 167 | border-color: #3a76d6; 168 | } 169 | 170 | #layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after { 171 | content: ""; 172 | position: absolute; 173 | display: none; 174 | left: 5px; 175 | top: 1px; 176 | width: 4px; 177 | height: 9px; 178 | border: solid white; 179 | border-width: 0 2px 2px 0; 180 | transform: rotate(45deg); 181 | } 182 | 183 | #layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after { 184 | display: block; 185 | } 186 | 187 | .layerforge-tooltip { 188 | position: fixed; 189 | background-color: #2f2f2f; 190 | color: #e0e0e0; 191 | padding: 8px 12px; 192 | border-radius: 6px; 193 | font-size: 12px; 194 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 195 | line-height: 1.4; 196 | max-width: 250px; 197 | word-wrap: break-word; 198 | box-shadow: 0 4px 12px rgba(0,0,0,0.4); 199 | border: 1px solid #202020; 200 | z-index: 10000; 201 | pointer-events: none; 202 | opacity: 0; 203 | transition: opacity 0.2s ease-in-out; 204 | } 205 | -------------------------------------------------------------------------------- /.github/workflows/ComfyUIdownloads.yml: -------------------------------------------------------------------------------- 1 | name: LayerForge Top Downloads Badge 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0,8,16 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: gh login 16 | run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token 17 | 18 | - name: Query LayerForge API 20 times and find top download 19 | run: | 20 | max_downloads=0 21 | top_node_json="{}" 22 | 23 | for i in {1..3}; do 24 | echo "Pobieranie danych z próby $i..." 25 | curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json 26 | 27 | if [ ! -s tmp_$i.json ] || ! jq empty tmp_$i.json 2>/dev/null; then 28 | echo "Błąd: Nieprawidłowy JSON dla próby $i" 29 | continue 30 | fi 31 | 32 | if jq -e 'type == "array"' tmp_$i.json >/dev/null; then 33 | # Przeszukanie wszystkich węzłów w tablicy 34 | node_count=$(jq 'length' tmp_$i.json) 35 | echo "Znaleziono $node_count węzłów w próbie $i" 36 | 37 | for j in $(seq 0 $((node_count - 1))); do 38 | downloads=$(jq -r ".[$j].downloads // 0" tmp_$i.json) 39 | name=$(jq -r ".[$j].name // \"\"" tmp_$i.json) 40 | 41 | if [ "$downloads" -gt "$max_downloads" ]; then 42 | max_downloads=$downloads 43 | top_node_json=$(jq ".[$j]" tmp_$i.json) 44 | echo "Nowe maksimum znalezione: $downloads (węzeł: $name)" 45 | fi 46 | done 47 | else 48 | downloads=$(jq -r '.downloads // 0' tmp_$i.json) 49 | name=$(jq -r '.name // ""' tmp_$i.json) 50 | 51 | if [ "$downloads" -gt "$max_downloads" ]; then 52 | max_downloads=$downloads 53 | top_node_json=$(cat tmp_$i.json) 54 | echo "Nowe maksimum znalezione: $downloads (węzeł: $name)" 55 | fi 56 | fi 57 | 58 | rm -f tmp_$i.json 59 | done 60 | 61 | if [ "$max_downloads" -gt 0 ]; then 62 | echo "$top_node_json" > top_layerforge.json 63 | echo "Najwyższa liczba pobrań: $max_downloads" 64 | echo "Szczegóły węzła:" 65 | jq . top_layerforge.json 66 | else 67 | echo "Błąd: Nie znaleziono żadnych prawidłowych danych" 68 | # Utworzenie domyślnego JSON-a 69 | echo '{"name": "No data", "downloads": 0}' > top_layerforge.json 70 | fi 71 | 72 | - name: create or update gist with top download 73 | id: set_id 74 | run: | 75 | if gh secret list | grep -q "LAYERFORGE_GIST_ID" 76 | then 77 | echo "GIST_ID found" 78 | echo "GIST=${{ secrets.LAYERFORGE_GIST_ID }}" >> $GITHUB_OUTPUT 79 | 80 | # Sprawdzenie czy gist istnieje 81 | if gh gist view ${{ secrets.LAYERFORGE_GIST_ID }} &>/dev/null; then 82 | echo "Gist istnieje, będzie zaktualizowany" 83 | else 84 | echo "Gist nie istnieje, tworzenie nowego" 85 | gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}') 86 | echo $gist_id | gh secret set LAYERFORGE_GIST_ID 87 | echo "GIST=$gist_id" >> $GITHUB_OUTPUT 88 | fi 89 | else 90 | echo "Tworzenie nowego gist" 91 | gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}') 92 | echo $gist_id | gh secret set LAYERFORGE_GIST_ID 93 | echo "GIST=$gist_id" >> $GITHUB_OUTPUT 94 | fi 95 | 96 | - name: create badge if needed 97 | run: | 98 | COUNT=$(jq '.downloads' top_layerforge.json) 99 | NAME=$(jq -r '.name' top_layerforge.json) 100 | if [ ! -f LAYERFORGE.md ]; then 101 | shields="https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=" 102 | url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/top_layerforge.json" 103 | repo="https://comfy.org" 104 | echo ''> LAYERFORGE.md 105 | echo ' 106 | **Markdown** 107 | 108 | ```markdown' >> LAYERFORGE.md 109 | echo "[![Top LayerForge Node]($shields$url)]($repo)" >> LAYERFORGE.md 110 | echo ' 111 | ``` 112 | 113 | **HTML** 114 | ```html' >> LAYERFORGE.md 115 | echo "Top LayerForge Node" >> LAYERFORGE.md 116 | echo '```' >> LAYERFORGE.md 117 | 118 | git add LAYERFORGE.md 119 | git config --global user.name "GitHub Action" 120 | git config --global user.email "action@github.com" 121 | git commit -m "Create LayerForge badge" 122 | fi 123 | 124 | - name: Update Gist 125 | run: | 126 | # Upewnienie się, że JSON jest poprawny 127 | if jq empty top_layerforge.json 2>/dev/null; then 128 | content=$(jq -c . top_layerforge.json) 129 | echo "{\"description\": \"Top LayerForge Node\", \"files\": {\"top_layerforge.json\": {\"content\": $(jq -Rs . <<< "$content")}}}" > patch.json 130 | 131 | curl -s -X PATCH \ 132 | --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \ 133 | -H "Content-Type: application/json" \ 134 | -d @patch.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} 135 | else 136 | echo "Błąd: Nieprawidłowy JSON w top_layerforge.json" 137 | exit 1 138 | fi 139 | 140 | - name: Push 141 | uses: ad-m/github-push-action@master 142 | with: 143 | github_token: ${{ secrets.GITHUB_TOKEN }} 144 | -------------------------------------------------------------------------------- /js/utils/mask_utils.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; 3 | const log = createModuleLogger('MaskUtils'); 4 | export function new_editor(app) { 5 | if (!app) 6 | return false; 7 | return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); 8 | } 9 | function get_mask_editor_element(app) { 10 | return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null; 11 | } 12 | export function mask_editor_showing(app) { 13 | const editor = get_mask_editor_element(app); 14 | return !!editor && editor.style.display !== "none"; 15 | } 16 | export function hide_mask_editor(app) { 17 | if (mask_editor_showing(app)) { 18 | const editor = document.getElementById('maskEditor'); 19 | if (editor) { 20 | editor.style.display = 'none'; 21 | } 22 | } 23 | } 24 | function get_mask_editor_cancel_button(app) { 25 | const cancelButton = document.getElementById("maskEditor_topBarCancelButton"); 26 | if (cancelButton) { 27 | log.debug("Found cancel button by ID: maskEditor_topBarCancelButton"); 28 | return cancelButton; 29 | } 30 | const cancelSelectors = [ 31 | 'button[onclick*="cancel"]', 32 | 'button[onclick*="Cancel"]', 33 | 'input[value="Cancel"]' 34 | ]; 35 | for (const selector of cancelSelectors) { 36 | try { 37 | const button = document.querySelector(selector); 38 | if (button) { 39 | log.debug("Found cancel button with selector:", selector); 40 | return button; 41 | } 42 | } 43 | catch (e) { 44 | log.warn("Invalid selector:", selector, e); 45 | } 46 | } 47 | const allButtons = document.querySelectorAll('button, input[type="button"]'); 48 | for (const button of allButtons) { 49 | const text = button.textContent || button.value || ''; 50 | if (text.toLowerCase().includes('cancel')) { 51 | log.debug("Found cancel button by text content:", text); 52 | return button; 53 | } 54 | } 55 | const editorElement = get_mask_editor_element(app); 56 | if (editorElement) { 57 | const childNodes = editorElement?.parentElement?.lastChild?.childNodes; 58 | if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { 59 | return childNodes[2]; 60 | } 61 | } 62 | return null; 63 | } 64 | function get_mask_editor_save_button(app) { 65 | const saveButton = document.getElementById("maskEditor_topBarSaveButton"); 66 | if (saveButton) { 67 | return saveButton; 68 | } 69 | const editorElement = get_mask_editor_element(app); 70 | if (editorElement) { 71 | const childNodes = editorElement?.parentElement?.lastChild?.childNodes; 72 | if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { 73 | return childNodes[2]; 74 | } 75 | } 76 | return null; 77 | } 78 | export function mask_editor_listen_for_cancel(app, callback) { 79 | let attempts = 0; 80 | const maxAttempts = 50; // 5 sekund 81 | const findAndAttachListener = () => { 82 | attempts++; 83 | const cancel_button = get_mask_editor_cancel_button(app); 84 | if (cancel_button instanceof HTMLElement && !cancel_button.filter_listener_added) { 85 | log.info("Cancel button found, attaching listener"); 86 | cancel_button.addEventListener('click', callback); 87 | cancel_button.filter_listener_added = true; 88 | } 89 | else if (attempts < maxAttempts) { 90 | setTimeout(findAndAttachListener, 100); 91 | } 92 | else { 93 | log.warn("Could not find cancel button after", maxAttempts, "attempts"); 94 | const globalClickHandler = (event) => { 95 | const target = event.target; 96 | const text = target.textContent || target.value || ''; 97 | if (target && (text.toLowerCase().includes('cancel') || 98 | target.id.toLowerCase().includes('cancel') || 99 | target.className.toLowerCase().includes('cancel'))) { 100 | log.info("Cancel detected via global click handler"); 101 | callback(); 102 | document.removeEventListener('click', globalClickHandler); 103 | } 104 | }; 105 | document.addEventListener('click', globalClickHandler); 106 | log.debug("Added global click handler for cancel detection"); 107 | } 108 | }; 109 | findAndAttachListener(); 110 | } 111 | export function press_maskeditor_save(app) { 112 | const button = get_mask_editor_save_button(app); 113 | if (button instanceof HTMLElement) { 114 | button.click(); 115 | } 116 | } 117 | export function press_maskeditor_cancel(app) { 118 | const button = get_mask_editor_cancel_button(app); 119 | if (button instanceof HTMLElement) { 120 | button.click(); 121 | } 122 | } 123 | /** 124 | * Uruchamia mask editor z predefiniowaną maską 125 | * @param {Canvas} canvasInstance - Instancja Canvas 126 | * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia 127 | * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) 128 | */ 129 | export const start_mask_editor_with_predefined_mask = withErrorHandling(function (canvasInstance, maskImage, sendCleanImage = true) { 130 | if (!canvasInstance) { 131 | throw createValidationError('Canvas instance is required', { canvasInstance }); 132 | } 133 | if (!maskImage) { 134 | throw createValidationError('Mask image is required', { maskImage }); 135 | } 136 | canvasInstance.startMaskEditor(maskImage, sendCleanImage); 137 | }, 'start_mask_editor_with_predefined_mask'); 138 | /** 139 | * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) 140 | * @param {Canvas} canvasInstance - Instancja Canvas 141 | */ 142 | export const start_mask_editor_auto = withErrorHandling(function (canvasInstance) { 143 | if (!canvasInstance) { 144 | throw createValidationError('Canvas instance is required', { canvasInstance }); 145 | } 146 | canvasInstance.startMaskEditor(null, true); 147 | }, 'start_mask_editor_auto'); 148 | // Duplikowane funkcje zostały przeniesione do ImageUtils.ts: 149 | // - create_mask_from_image_src -> createMaskFromImageSrc 150 | // - canvas_to_mask_image -> canvasToMaskImage 151 | -------------------------------------------------------------------------------- /src/utils/ImageUploadUtils.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import { api } from "../../../scripts/api.js"; 3 | import { createModuleLogger } from "./LoggerUtils.js"; 4 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 5 | 6 | const log = createModuleLogger('ImageUploadUtils'); 7 | 8 | /** 9 | * Utility functions for uploading images to ComfyUI server 10 | */ 11 | 12 | export interface UploadImageOptions { 13 | /** Custom filename prefix (default: 'layerforge') */ 14 | filenamePrefix?: string; 15 | /** Whether to overwrite existing files (default: true) */ 16 | overwrite?: boolean; 17 | /** Upload type (default: 'temp') */ 18 | type?: string; 19 | /** Node ID for unique filename generation */ 20 | nodeId?: string | number; 21 | } 22 | 23 | export interface UploadImageResult { 24 | /** Server response data */ 25 | data: any; 26 | /** Generated filename */ 27 | filename: string; 28 | /** Full image URL */ 29 | imageUrl: string; 30 | /** Created Image element */ 31 | imageElement: HTMLImageElement; 32 | } 33 | 34 | /** 35 | * Uploads an image blob to ComfyUI server and returns image element 36 | * @param blob - Image blob to upload 37 | * @param options - Upload options 38 | * @returns Promise with upload result 39 | */ 40 | export const uploadImageBlob = withErrorHandling(async function(blob: Blob, options: UploadImageOptions = {}): Promise { 41 | if (!blob) { 42 | throw createValidationError("Blob is required", { blob }); 43 | } 44 | if (blob.size === 0) { 45 | throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); 46 | } 47 | 48 | const { 49 | filenamePrefix = 'layerforge', 50 | overwrite = true, 51 | type = 'temp', 52 | nodeId 53 | } = options; 54 | 55 | // Generate unique filename 56 | const timestamp = Date.now(); 57 | const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; 58 | const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; 59 | 60 | log.debug('Uploading image blob:', { 61 | filename, 62 | blobSize: blob.size, 63 | type, 64 | overwrite 65 | }); 66 | 67 | // Create FormData 68 | const formData = new FormData(); 69 | formData.append("image", blob, filename); 70 | formData.append("overwrite", overwrite.toString()); 71 | formData.append("type", type); 72 | 73 | // Upload to server 74 | const response = await api.fetchApi("/upload/image", { 75 | method: "POST", 76 | body: formData, 77 | }); 78 | 79 | if (!response.ok) { 80 | throw createNetworkError(`Failed to upload image: ${response.statusText}`, { 81 | status: response.status, 82 | statusText: response.statusText, 83 | filename, 84 | blobSize: blob.size 85 | }); 86 | } 87 | 88 | const data = await response.json(); 89 | log.debug('Image uploaded successfully:', data); 90 | 91 | // Create image element with proper URL 92 | const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); 93 | const imageElement = new Image(); 94 | imageElement.crossOrigin = "anonymous"; 95 | 96 | // Wait for image to load 97 | await new Promise((resolve, reject) => { 98 | imageElement.onload = () => { 99 | log.debug("Uploaded image loaded successfully", { 100 | width: imageElement.width, 101 | height: imageElement.height, 102 | src: imageElement.src.substring(0, 100) + '...' 103 | }); 104 | resolve(); 105 | }; 106 | imageElement.onerror = (error) => { 107 | log.error("Failed to load uploaded image", error); 108 | reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename })); 109 | }; 110 | imageElement.src = imageUrl; 111 | }); 112 | 113 | return { 114 | data, 115 | filename, 116 | imageUrl, 117 | imageElement 118 | }; 119 | }, 'uploadImageBlob'); 120 | 121 | /** 122 | * Uploads canvas content as image blob 123 | * @param canvas - Canvas element or Canvas object with canvasLayers 124 | * @param options - Upload options 125 | * @returns Promise with upload result 126 | */ 127 | export const uploadCanvasAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise { 128 | if (!canvas) { 129 | throw createValidationError("Canvas is required", { canvas }); 130 | } 131 | 132 | let blob: Blob | null = null; 133 | 134 | // Handle different canvas types 135 | if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { 136 | // LayerForge Canvas object 137 | blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); 138 | } else if (canvas instanceof HTMLCanvasElement) { 139 | // Standard HTML Canvas 140 | blob = await new Promise(resolve => canvas.toBlob(resolve)); 141 | } else { 142 | throw createValidationError("Unsupported canvas type", { 143 | canvas, 144 | hasCanvasLayers: !!canvas.canvasLayers, 145 | isHTMLCanvas: canvas instanceof HTMLCanvasElement 146 | }); 147 | } 148 | 149 | if (!blob) { 150 | throw createValidationError("Failed to generate canvas blob", { canvas, options }); 151 | } 152 | 153 | return uploadImageBlob(blob, options); 154 | }, 'uploadCanvasAsImage'); 155 | 156 | /** 157 | * Uploads canvas with mask as image blob 158 | * @param canvas - Canvas object with canvasLayers 159 | * @param options - Upload options 160 | * @returns Promise with upload result 161 | */ 162 | export const uploadCanvasWithMaskAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise { 163 | if (!canvas) { 164 | throw createValidationError("Canvas is required", { canvas }); 165 | } 166 | if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') { 167 | throw createValidationError("Canvas does not support mask operations", { 168 | canvas, 169 | hasCanvasLayers: !!canvas.canvasLayers, 170 | hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') 171 | }); 172 | } 173 | 174 | const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); 175 | if (!blob) { 176 | throw createValidationError("Failed to generate canvas with mask blob", { canvas, options }); 177 | } 178 | 179 | return uploadImageBlob(blob, options); 180 | }, 'uploadCanvasWithMaskAsImage'); 181 | -------------------------------------------------------------------------------- /js/CanvasSelection.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | import { generateUUID } from "./utils/CommonUtils.js"; 3 | const log = createModuleLogger('CanvasSelection'); 4 | export class CanvasSelection { 5 | constructor(canvas) { 6 | this.canvas = canvas; 7 | this.selectedLayers = []; 8 | this.selectedLayer = null; 9 | this.onSelectionChange = null; 10 | } 11 | /** 12 | * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) 13 | */ 14 | duplicateSelectedLayers() { 15 | if (this.selectedLayers.length === 0) 16 | return []; 17 | const newLayers = []; 18 | const sortedLayers = [...this.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); 19 | sortedLayers.forEach(layer => { 20 | const newLayer = { 21 | ...layer, 22 | id: generateUUID(), 23 | zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu 24 | }; 25 | this.canvas.layers.push(newLayer); 26 | newLayers.push(newLayer); 27 | }); 28 | // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) 29 | this.updateSelection(newLayers); 30 | // Powiadom panel o zmianie struktury, aby się przerysował 31 | if (this.canvas.canvasLayersPanel) { 32 | this.canvas.canvasLayersPanel.onLayersChanged(); 33 | } 34 | log.info(`Duplicated ${newLayers.length} layers (in-memory).`); 35 | return newLayers; 36 | } 37 | /** 38 | * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. 39 | * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. 40 | * @param {Array} newSelection - Nowa lista zaznaczonych warstw 41 | */ 42 | updateSelection(newSelection) { 43 | const previousSelection = this.selectedLayers.length; 44 | // Filter out invisible layers from selection 45 | this.selectedLayers = (newSelection || []).filter((layer) => layer.visible !== false); 46 | this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; 47 | // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli 48 | const hasChanged = previousSelection !== this.selectedLayers.length || 49 | this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); 50 | if (!hasChanged && previousSelection > 0) { 51 | // return; // Zablokowane na razie, może powodować problemy 52 | } 53 | log.debug('Selection updated', { 54 | previousCount: previousSelection, 55 | newCount: this.selectedLayers.length, 56 | selectedLayerIds: this.selectedLayers.map((l) => l.id || 'unknown') 57 | }); 58 | // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji 59 | this.canvas.render(); 60 | // 2. Powiadom inne części aplikacji (jeśli są) 61 | if (this.onSelectionChange) { 62 | this.onSelectionChange(); 63 | } 64 | // 3. Powiadom panel warstw, aby zaktualizował swój wygląd 65 | if (this.canvas.canvasLayersPanel) { 66 | this.canvas.canvasLayersPanel.onSelectionChanged(); 67 | } 68 | } 69 | /** 70 | * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. 71 | */ 72 | updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { 73 | let newSelection = [...this.selectedLayers]; 74 | let selectionChanged = false; 75 | if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { 76 | const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); 77 | const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); 78 | const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); 79 | newSelection = []; 80 | for (let i = startIndex; i <= endIndex; i++) { 81 | if (sortedLayers[i]) { 82 | newSelection.push(sortedLayers[i]); 83 | } 84 | } 85 | selectionChanged = true; 86 | } 87 | else if (isCtrlPressed) { 88 | const layerIndex = newSelection.indexOf(layer); 89 | if (layerIndex === -1) { 90 | newSelection.push(layer); 91 | } 92 | else { 93 | newSelection.splice(layerIndex, 1); 94 | } 95 | this.canvas.canvasLayersPanel.lastSelectedIndex = index; 96 | selectionChanged = true; 97 | } 98 | else { 99 | // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, 100 | // wyczyść zaznaczenie i zaznacz tylko ją. 101 | if (!this.selectedLayers.includes(layer)) { 102 | newSelection = [layer]; 103 | selectionChanged = true; 104 | } 105 | // Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi), 106 | // NIE rób nic, aby umożliwić przeciąganie całej grupy. 107 | this.canvas.canvasLayersPanel.lastSelectedIndex = index; 108 | } 109 | // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło 110 | if (selectionChanged) { 111 | this.updateSelection(newSelection); 112 | } 113 | } 114 | removeSelectedLayers() { 115 | if (this.selectedLayers.length > 0) { 116 | log.info('Removing selected layers', { 117 | layersToRemove: this.selectedLayers.length, 118 | totalLayers: this.canvas.layers.length 119 | }); 120 | this.canvas.saveState(); 121 | this.canvas.layers = this.canvas.layers.filter((l) => !this.selectedLayers.includes(l)); 122 | this.updateSelection([]); 123 | this.canvas.render(); 124 | this.canvas.saveState(); 125 | if (this.canvas.canvasLayersPanel) { 126 | this.canvas.canvasLayersPanel.onLayersChanged(); 127 | } 128 | log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); 129 | } 130 | else { 131 | log.debug('No layers selected for removal'); 132 | } 133 | } 134 | /** 135 | * Aktualizuje zaznaczenie po operacji historii 136 | */ 137 | updateSelectionAfterHistory() { 138 | const newSelectedLayers = []; 139 | if (this.selectedLayers) { 140 | this.selectedLayers.forEach((sl) => { 141 | const found = this.canvas.layers.find((l) => l.id === sl.id); 142 | if (found) 143 | newSelectedLayers.push(found); 144 | }); 145 | } 146 | this.updateSelection(newSelectedLayers); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /js/db.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | const log = createModuleLogger('db'); 3 | const DB_NAME = 'CanvasNodeDB'; 4 | const STATE_STORE_NAME = 'CanvasState'; 5 | const IMAGE_STORE_NAME = 'CanvasImages'; 6 | const DB_VERSION = 3; 7 | let db = null; 8 | /** 9 | * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów 10 | * @param {IDBObjectStore} store - Store IndexedDB 11 | * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear) 12 | * @param {any} data - Dane dla operacji (opcjonalne) 13 | * @param {string} errorMessage - Wiadomość błędu 14 | * @returns {Promise} Promise z wynikiem operacji 15 | */ 16 | function createDBRequest(store, operation, data, errorMessage) { 17 | return new Promise((resolve, reject) => { 18 | let request; 19 | switch (operation) { 20 | case 'get': 21 | request = store.get(data); 22 | break; 23 | case 'put': 24 | request = store.put(data); 25 | break; 26 | case 'delete': 27 | request = store.delete(data); 28 | break; 29 | case 'clear': 30 | request = store.clear(); 31 | break; 32 | default: 33 | reject(new Error(`Unknown operation: ${operation}`)); 34 | return; 35 | } 36 | request.onerror = (event) => { 37 | log.error(errorMessage, event.target.error); 38 | reject(errorMessage); 39 | }; 40 | request.onsuccess = (event) => { 41 | resolve(event.target.result); 42 | }; 43 | }); 44 | } 45 | function openDB() { 46 | return new Promise((resolve, reject) => { 47 | if (db) { 48 | resolve(db); 49 | return; 50 | } 51 | log.info("Opening IndexedDB..."); 52 | const request = indexedDB.open(DB_NAME, DB_VERSION); 53 | request.onerror = (event) => { 54 | log.error("IndexedDB error:", event.target.error); 55 | reject("Error opening IndexedDB."); 56 | }; 57 | request.onsuccess = (event) => { 58 | db = event.target.result; 59 | log.info("IndexedDB opened successfully."); 60 | resolve(db); 61 | }; 62 | request.onupgradeneeded = (event) => { 63 | log.info("Upgrading IndexedDB..."); 64 | const dbInstance = event.target.result; 65 | if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) { 66 | dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' }); 67 | log.info("Object store created:", STATE_STORE_NAME); 68 | } 69 | if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) { 70 | dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' }); 71 | log.info("Object store created:", IMAGE_STORE_NAME); 72 | } 73 | }; 74 | }); 75 | } 76 | export async function getCanvasState(id) { 77 | log.info(`Getting state for id: ${id}`); 78 | const db = await openDB(); 79 | const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); 80 | const store = transaction.objectStore(STATE_STORE_NAME); 81 | const result = await createDBRequest(store, 'get', id, "Error getting canvas state"); 82 | log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); 83 | return result ? result.state : null; 84 | } 85 | export async function setCanvasState(id, state) { 86 | log.info(`Setting state for id: ${id}`); 87 | const db = await openDB(); 88 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 89 | const store = transaction.objectStore(STATE_STORE_NAME); 90 | await createDBRequest(store, 'put', { id, state }, "Error setting canvas state"); 91 | log.debug(`Set success for id: ${id}`); 92 | } 93 | export async function removeCanvasState(id) { 94 | log.info(`Removing state for id: ${id}`); 95 | const db = await openDB(); 96 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 97 | const store = transaction.objectStore(STATE_STORE_NAME); 98 | await createDBRequest(store, 'delete', id, "Error removing canvas state"); 99 | log.debug(`Remove success for id: ${id}`); 100 | } 101 | export async function saveImage(imageId, imageSrc) { 102 | log.info(`Saving image with id: ${imageId}`); 103 | const db = await openDB(); 104 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); 105 | const store = transaction.objectStore(IMAGE_STORE_NAME); 106 | await createDBRequest(store, 'put', { imageId, imageSrc }, "Error saving image"); 107 | log.debug(`Image saved successfully for id: ${imageId}`); 108 | } 109 | export async function getImage(imageId) { 110 | log.info(`Getting image with id: ${imageId}`); 111 | const db = await openDB(); 112 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); 113 | const store = transaction.objectStore(IMAGE_STORE_NAME); 114 | const result = await createDBRequest(store, 'get', imageId, "Error getting image"); 115 | log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); 116 | return result ? result.imageSrc : null; 117 | } 118 | export async function removeImage(imageId) { 119 | log.info(`Removing image with id: ${imageId}`); 120 | const db = await openDB(); 121 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); 122 | const store = transaction.objectStore(IMAGE_STORE_NAME); 123 | await createDBRequest(store, 'delete', imageId, "Error removing image"); 124 | log.debug(`Remove image success for id: ${imageId}`); 125 | } 126 | export async function getAllImageIds() { 127 | log.info("Getting all image IDs..."); 128 | const db = await openDB(); 129 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); 130 | const store = transaction.objectStore(IMAGE_STORE_NAME); 131 | return new Promise((resolve, reject) => { 132 | const request = store.getAllKeys(); 133 | request.onerror = (event) => { 134 | log.error("Error getting all image IDs:", event.target.error); 135 | reject("Error getting all image IDs"); 136 | }; 137 | request.onsuccess = (event) => { 138 | const imageIds = event.target.result; 139 | log.debug(`Found ${imageIds.length} image IDs in database`); 140 | resolve(imageIds); 141 | }; 142 | }); 143 | } 144 | export async function clearAllCanvasStates() { 145 | log.info("Clearing all canvas states..."); 146 | const db = await openDB(); 147 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 148 | const store = transaction.objectStore(STATE_STORE_NAME); 149 | await createDBRequest(store, 'clear', null, "Error clearing canvas states"); 150 | log.info("All canvas states cleared successfully."); 151 | } 152 | -------------------------------------------------------------------------------- /js/utils/WebSocketManager.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 3 | const log = createModuleLogger('WebSocketManager'); 4 | class WebSocketManager { 5 | constructor(url) { 6 | this.url = url; 7 | this.connect = withErrorHandling(() => { 8 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 9 | log.debug("WebSocket is already open."); 10 | return; 11 | } 12 | if (this.isConnecting) { 13 | log.debug("Connection attempt already in progress."); 14 | return; 15 | } 16 | if (!this.url) { 17 | throw createValidationError("WebSocket URL is required", { url: this.url }); 18 | } 19 | this.isConnecting = true; 20 | log.info(`Connecting to WebSocket at ${this.url}...`); 21 | this.socket = new WebSocket(this.url); 22 | this.socket.onopen = () => { 23 | this.isConnecting = false; 24 | this.reconnectAttempts = 0; 25 | log.info("WebSocket connection established."); 26 | this.flushMessageQueue(); 27 | }; 28 | this.socket.onmessage = (event) => { 29 | try { 30 | const data = JSON.parse(event.data); 31 | log.debug("Received message:", data); 32 | if (data.type === 'ack' && data.nodeId) { 33 | const callback = this.ackCallbacks.get(data.nodeId); 34 | if (callback) { 35 | log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`); 36 | callback.resolve(data); 37 | this.ackCallbacks.delete(data.nodeId); 38 | } 39 | } 40 | } 41 | catch (error) { 42 | log.error("Error parsing incoming WebSocket message:", error); 43 | } 44 | }; 45 | this.socket.onclose = (event) => { 46 | this.isConnecting = false; 47 | if (event.wasClean) { 48 | log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); 49 | } 50 | else { 51 | log.warn("WebSocket connection died. Attempting to reconnect..."); 52 | this.handleReconnect(); 53 | } 54 | }; 55 | this.socket.onerror = (error) => { 56 | this.isConnecting = false; 57 | throw createNetworkError("WebSocket connection error", { error, url: this.url }); 58 | }; 59 | }, 'WebSocketManager.connect'); 60 | this.sendMessage = withErrorHandling(async (data, requiresAck = false) => { 61 | if (!data || typeof data !== 'object') { 62 | throw createValidationError("Message data is required", { data }); 63 | } 64 | const nodeId = data.nodeId; 65 | if (requiresAck && !nodeId) { 66 | throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck }); 67 | } 68 | return new Promise((resolve, reject) => { 69 | const message = JSON.stringify(data); 70 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 71 | this.socket.send(message); 72 | log.debug("Sent message:", data); 73 | if (requiresAck && nodeId) { 74 | log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); 75 | const timeout = setTimeout(() => { 76 | this.ackCallbacks.delete(nodeId); 77 | reject(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 })); 78 | log.warn(`ACK timeout for nodeId ${nodeId}.`); 79 | }, 10000); // 10-second timeout 80 | this.ackCallbacks.set(nodeId, { 81 | resolve: (responseData) => { 82 | clearTimeout(timeout); 83 | resolve(responseData); 84 | }, 85 | reject: (error) => { 86 | clearTimeout(timeout); 87 | reject(error); 88 | } 89 | }); 90 | } 91 | else { 92 | resolve(); // Resolve immediately if no ACK is needed 93 | } 94 | } 95 | else { 96 | log.warn("WebSocket not open. Queuing message."); 97 | this.messageQueue.push(message); 98 | if (!this.isConnecting) { 99 | this.connect(); 100 | } 101 | if (requiresAck) { 102 | reject(createNetworkError("Cannot send message with ACK required while disconnected", { 103 | socketState: this.socket?.readyState, 104 | isConnecting: this.isConnecting 105 | })); 106 | } 107 | else { 108 | resolve(); 109 | } 110 | } 111 | }); 112 | }, 'WebSocketManager.sendMessage'); 113 | this.socket = null; 114 | this.messageQueue = []; 115 | this.isConnecting = false; 116 | this.reconnectAttempts = 0; 117 | this.maxReconnectAttempts = 10; 118 | this.reconnectInterval = 5000; // 5 seconds 119 | this.ackCallbacks = new Map(); 120 | this.messageIdCounter = 0; 121 | this.connect(); 122 | } 123 | handleReconnect() { 124 | if (this.reconnectAttempts < this.maxReconnectAttempts) { 125 | this.reconnectAttempts++; 126 | log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); 127 | setTimeout(() => this.connect(), this.reconnectInterval); 128 | } 129 | else { 130 | log.error("Max reconnect attempts reached. Giving up."); 131 | } 132 | } 133 | flushMessageQueue() { 134 | log.debug(`Flushing ${this.messageQueue.length} queued messages.`); 135 | while (this.messageQueue.length > 0) { 136 | const message = this.messageQueue.shift(); 137 | if (this.socket && message) { 138 | this.socket.send(message); 139 | } 140 | } 141 | } 142 | } 143 | const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`; 144 | export const webSocketManager = new WebSocketManager(wsUrl); 145 | -------------------------------------------------------------------------------- /src/utils/WebSocketManager.ts: -------------------------------------------------------------------------------- 1 | import {createModuleLogger} from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; 3 | import type { WebSocketMessage, AckCallbacks } from "../types.js"; 4 | 5 | const log = createModuleLogger('WebSocketManager'); 6 | 7 | class WebSocketManager { 8 | private socket: WebSocket | null; 9 | private messageQueue: string[]; 10 | private isConnecting: boolean; 11 | private reconnectAttempts: number; 12 | private readonly maxReconnectAttempts: number; 13 | private readonly reconnectInterval: number; 14 | private ackCallbacks: AckCallbacks; 15 | private messageIdCounter: number; 16 | 17 | constructor(private url: string) { 18 | this.socket = null; 19 | this.messageQueue = []; 20 | this.isConnecting = false; 21 | this.reconnectAttempts = 0; 22 | this.maxReconnectAttempts = 10; 23 | this.reconnectInterval = 5000; // 5 seconds 24 | this.ackCallbacks = new Map(); 25 | this.messageIdCounter = 0; 26 | 27 | this.connect(); 28 | } 29 | 30 | connect = withErrorHandling(() => { 31 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 32 | log.debug("WebSocket is already open."); 33 | return; 34 | } 35 | 36 | if (this.isConnecting) { 37 | log.debug("Connection attempt already in progress."); 38 | return; 39 | } 40 | 41 | if (!this.url) { 42 | throw createValidationError("WebSocket URL is required", { url: this.url }); 43 | } 44 | 45 | this.isConnecting = true; 46 | log.info(`Connecting to WebSocket at ${this.url}...`); 47 | 48 | this.socket = new WebSocket(this.url); 49 | 50 | this.socket.onopen = () => { 51 | this.isConnecting = false; 52 | this.reconnectAttempts = 0; 53 | log.info("WebSocket connection established."); 54 | this.flushMessageQueue(); 55 | }; 56 | 57 | this.socket.onmessage = (event: MessageEvent) => { 58 | try { 59 | const data: WebSocketMessage = JSON.parse(event.data); 60 | log.debug("Received message:", data); 61 | 62 | if (data.type === 'ack' && data.nodeId) { 63 | const callback = this.ackCallbacks.get(data.nodeId); 64 | if (callback) { 65 | log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`); 66 | callback.resolve(data); 67 | this.ackCallbacks.delete(data.nodeId); 68 | } 69 | } 70 | 71 | } catch (error) { 72 | log.error("Error parsing incoming WebSocket message:", error); 73 | } 74 | }; 75 | 76 | this.socket.onclose = (event: CloseEvent) => { 77 | this.isConnecting = false; 78 | if (event.wasClean) { 79 | log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); 80 | } else { 81 | log.warn("WebSocket connection died. Attempting to reconnect..."); 82 | this.handleReconnect(); 83 | } 84 | }; 85 | 86 | this.socket.onerror = (error: Event) => { 87 | this.isConnecting = false; 88 | throw createNetworkError("WebSocket connection error", { error, url: this.url }); 89 | }; 90 | }, 'WebSocketManager.connect'); 91 | 92 | handleReconnect() { 93 | if (this.reconnectAttempts < this.maxReconnectAttempts) { 94 | this.reconnectAttempts++; 95 | log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); 96 | setTimeout(() => this.connect(), this.reconnectInterval); 97 | } else { 98 | log.error("Max reconnect attempts reached. Giving up."); 99 | } 100 | } 101 | 102 | sendMessage = withErrorHandling(async (data: WebSocketMessage, requiresAck = false): Promise => { 103 | if (!data || typeof data !== 'object') { 104 | throw createValidationError("Message data is required", { data }); 105 | } 106 | 107 | const nodeId = data.nodeId; 108 | if (requiresAck && !nodeId) { 109 | throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck }); 110 | } 111 | 112 | return new Promise((resolve, reject) => { 113 | const message = JSON.stringify(data); 114 | 115 | if (this.socket && this.socket.readyState === WebSocket.OPEN) { 116 | this.socket.send(message); 117 | log.debug("Sent message:", data); 118 | if (requiresAck && nodeId) { 119 | log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); 120 | 121 | const timeout = setTimeout(() => { 122 | this.ackCallbacks.delete(nodeId); 123 | reject(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 })); 124 | log.warn(`ACK timeout for nodeId ${nodeId}.`); 125 | }, 10000); // 10-second timeout 126 | 127 | this.ackCallbacks.set(nodeId, { 128 | resolve: (responseData: WebSocketMessage | PromiseLike) => { 129 | clearTimeout(timeout); 130 | resolve(responseData); 131 | }, 132 | reject: (error: any) => { 133 | clearTimeout(timeout); 134 | reject(error); 135 | } 136 | }); 137 | } else { 138 | resolve(); // Resolve immediately if no ACK is needed 139 | } 140 | } else { 141 | log.warn("WebSocket not open. Queuing message."); 142 | this.messageQueue.push(message); 143 | if (!this.isConnecting) { 144 | this.connect(); 145 | } 146 | 147 | if (requiresAck) { 148 | reject(createNetworkError("Cannot send message with ACK required while disconnected", { 149 | socketState: this.socket?.readyState, 150 | isConnecting: this.isConnecting 151 | })); 152 | } else { 153 | resolve(); 154 | } 155 | } 156 | }); 157 | }, 'WebSocketManager.sendMessage'); 158 | 159 | flushMessageQueue() { 160 | log.debug(`Flushing ${this.messageQueue.length} queued messages.`); 161 | 162 | while (this.messageQueue.length > 0) { 163 | const message = this.messageQueue.shift(); 164 | if (this.socket && message) { 165 | this.socket.send(message); 166 | } 167 | } 168 | } 169 | } 170 | 171 | const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`; 172 | export const webSocketManager = new WebSocketManager(wsUrl); 173 | -------------------------------------------------------------------------------- /src/CanvasSelection.ts: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./utils/LoggerUtils.js"; 2 | import { generateUUID } from "./utils/CommonUtils.js"; 3 | 4 | const log = createModuleLogger('CanvasSelection'); 5 | 6 | export class CanvasSelection { 7 | canvas: any; 8 | onSelectionChange: any; 9 | selectedLayer: any; 10 | selectedLayers: any; 11 | constructor(canvas: any) { 12 | this.canvas = canvas; 13 | this.selectedLayers = []; 14 | this.selectedLayer = null; 15 | this.onSelectionChange = null; 16 | } 17 | 18 | /** 19 | * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) 20 | */ 21 | duplicateSelectedLayers() { 22 | if (this.selectedLayers.length === 0) return []; 23 | 24 | const newLayers: any = []; 25 | const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex); 26 | 27 | sortedLayers.forEach(layer => { 28 | const newLayer = { 29 | ...layer, 30 | id: generateUUID(), 31 | zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu 32 | }; 33 | this.canvas.layers.push(newLayer); 34 | newLayers.push(newLayer); 35 | }); 36 | 37 | // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) 38 | this.updateSelection(newLayers); 39 | 40 | // Powiadom panel o zmianie struktury, aby się przerysował 41 | if (this.canvas.canvasLayersPanel) { 42 | this.canvas.canvasLayersPanel.onLayersChanged(); 43 | } 44 | 45 | log.info(`Duplicated ${newLayers.length} layers (in-memory).`); 46 | return newLayers; 47 | } 48 | 49 | /** 50 | * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. 51 | * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. 52 | * @param {Array} newSelection - Nowa lista zaznaczonych warstw 53 | */ 54 | updateSelection(newSelection: any) { 55 | const previousSelection = this.selectedLayers.length; 56 | // Filter out invisible layers from selection 57 | this.selectedLayers = (newSelection || []).filter((layer: any) => layer.visible !== false); 58 | this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; 59 | 60 | // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli 61 | const hasChanged = previousSelection !== this.selectedLayers.length || 62 | this.selectedLayers.some((layer: any, i: any) => this.selectedLayers[i] !== (newSelection || [])[i]); 63 | 64 | if (!hasChanged && previousSelection > 0) { 65 | // return; // Zablokowane na razie, może powodować problemy 66 | } 67 | 68 | log.debug('Selection updated', { 69 | previousCount: previousSelection, 70 | newCount: this.selectedLayers.length, 71 | selectedLayerIds: this.selectedLayers.map((l: any) => l.id || 'unknown') 72 | }); 73 | 74 | // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji 75 | this.canvas.render(); 76 | 77 | // 2. Powiadom inne części aplikacji (jeśli są) 78 | if (this.onSelectionChange) { 79 | this.onSelectionChange(); 80 | } 81 | 82 | // 3. Powiadom panel warstw, aby zaktualizował swój wygląd 83 | if (this.canvas.canvasLayersPanel) { 84 | this.canvas.canvasLayersPanel.onSelectionChanged(); 85 | } 86 | } 87 | 88 | /** 89 | * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. 90 | */ 91 | updateSelectionLogic(layer: any, isCtrlPressed: any, isShiftPressed: any, index: any) { 92 | let newSelection = [...this.selectedLayers]; 93 | let selectionChanged = false; 94 | 95 | if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { 96 | const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); 97 | const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); 98 | const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); 99 | 100 | newSelection = []; 101 | for (let i = startIndex; i <= endIndex; i++) { 102 | if (sortedLayers[i]) { 103 | newSelection.push(sortedLayers[i]); 104 | } 105 | } 106 | selectionChanged = true; 107 | } else if (isCtrlPressed) { 108 | const layerIndex = newSelection.indexOf(layer); 109 | if (layerIndex === -1) { 110 | newSelection.push(layer); 111 | } else { 112 | newSelection.splice(layerIndex, 1); 113 | } 114 | this.canvas.canvasLayersPanel.lastSelectedIndex = index; 115 | selectionChanged = true; 116 | } else { 117 | // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, 118 | // wyczyść zaznaczenie i zaznacz tylko ją. 119 | if (!this.selectedLayers.includes(layer)) { 120 | newSelection = [layer]; 121 | selectionChanged = true; 122 | } 123 | // Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi), 124 | // NIE rób nic, aby umożliwić przeciąganie całej grupy. 125 | this.canvas.canvasLayersPanel.lastSelectedIndex = index; 126 | } 127 | 128 | // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło 129 | if (selectionChanged) { 130 | this.updateSelection(newSelection); 131 | } 132 | } 133 | 134 | removeSelectedLayers() { 135 | if (this.selectedLayers.length > 0) { 136 | log.info('Removing selected layers', { 137 | layersToRemove: this.selectedLayers.length, 138 | totalLayers: this.canvas.layers.length 139 | }); 140 | 141 | this.canvas.saveState(); 142 | this.canvas.layers = this.canvas.layers.filter((l: any) => !this.selectedLayers.includes(l)); 143 | 144 | this.updateSelection([]); 145 | 146 | this.canvas.render(); 147 | this.canvas.saveState(); 148 | 149 | if (this.canvas.canvasLayersPanel) { 150 | this.canvas.canvasLayersPanel.onLayersChanged(); 151 | } 152 | 153 | log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); 154 | } else { 155 | log.debug('No layers selected for removal'); 156 | } 157 | } 158 | 159 | /** 160 | * Aktualizuje zaznaczenie po operacji historii 161 | */ 162 | updateSelectionAfterHistory() { 163 | const newSelectedLayers: any = []; 164 | if (this.selectedLayers) { 165 | this.selectedLayers.forEach((sl: any) => { 166 | const found = this.canvas.layers.find((l: any) => l.id === sl.id); 167 | if (found) newSelectedLayers.push(found); 168 | }); 169 | } 170 | this.updateSelection(newSelectedLayers); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/utils/mask_utils.ts: -------------------------------------------------------------------------------- 1 | import {createModuleLogger} from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; 3 | import type { Canvas } from '../Canvas.js'; 4 | // @ts-ignore 5 | import {ComfyApp} from "../../../scripts/app.js"; 6 | 7 | const log = createModuleLogger('MaskUtils'); 8 | 9 | export function new_editor(app: ComfyApp): boolean { 10 | if (!app) return false; 11 | return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); 12 | } 13 | 14 | function get_mask_editor_element(app: ComfyApp): HTMLElement | null { 15 | return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null; 16 | } 17 | 18 | export function mask_editor_showing(app: ComfyApp): boolean { 19 | const editor = get_mask_editor_element(app); 20 | return !!editor && editor.style.display !== "none"; 21 | } 22 | 23 | export function hide_mask_editor(app: ComfyApp): void { 24 | if (mask_editor_showing(app)) { 25 | const editor = document.getElementById('maskEditor'); 26 | if (editor) { 27 | editor.style.display = 'none'; 28 | } 29 | } 30 | } 31 | 32 | function get_mask_editor_cancel_button(app: ComfyApp): HTMLElement | null { 33 | const cancelButton = document.getElementById("maskEditor_topBarCancelButton"); 34 | if (cancelButton) { 35 | log.debug("Found cancel button by ID: maskEditor_topBarCancelButton"); 36 | return cancelButton; 37 | } 38 | 39 | const cancelSelectors = [ 40 | 'button[onclick*="cancel"]', 41 | 'button[onclick*="Cancel"]', 42 | 'input[value="Cancel"]' 43 | ]; 44 | 45 | for (const selector of cancelSelectors) { 46 | try { 47 | const button = document.querySelector(selector); 48 | if (button) { 49 | log.debug("Found cancel button with selector:", selector); 50 | return button; 51 | } 52 | } catch (e) { 53 | log.warn("Invalid selector:", selector, e); 54 | } 55 | } 56 | 57 | const allButtons = document.querySelectorAll('button, input[type="button"]'); 58 | for (const button of allButtons) { 59 | const text = (button as HTMLElement).textContent || (button as HTMLInputElement).value || ''; 60 | if (text.toLowerCase().includes('cancel')) { 61 | log.debug("Found cancel button by text content:", text); 62 | return button as HTMLElement; 63 | } 64 | } 65 | 66 | const editorElement = get_mask_editor_element(app); 67 | if (editorElement) { 68 | const childNodes = editorElement?.parentElement?.lastChild?.childNodes; 69 | if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { 70 | return childNodes[2]; 71 | } 72 | } 73 | 74 | return null; 75 | } 76 | 77 | function get_mask_editor_save_button(app: ComfyApp): HTMLElement | null { 78 | const saveButton = document.getElementById("maskEditor_topBarSaveButton"); 79 | if (saveButton) { 80 | return saveButton; 81 | } 82 | const editorElement = get_mask_editor_element(app); 83 | if (editorElement) { 84 | const childNodes = editorElement?.parentElement?.lastChild?.childNodes; 85 | if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { 86 | return childNodes[2]; 87 | } 88 | } 89 | return null; 90 | } 91 | 92 | export function mask_editor_listen_for_cancel(app: ComfyApp, callback: () => void): void { 93 | let attempts = 0; 94 | const maxAttempts = 50; // 5 sekund 95 | 96 | const findAndAttachListener = () => { 97 | attempts++; 98 | const cancel_button = get_mask_editor_cancel_button(app); 99 | 100 | if (cancel_button instanceof HTMLElement && !(cancel_button as any).filter_listener_added) { 101 | log.info("Cancel button found, attaching listener"); 102 | cancel_button.addEventListener('click', callback); 103 | (cancel_button as any).filter_listener_added = true; 104 | } else if (attempts < maxAttempts) { 105 | 106 | setTimeout(findAndAttachListener, 100); 107 | } else { 108 | log.warn("Could not find cancel button after", maxAttempts, "attempts"); 109 | 110 | const globalClickHandler = (event: MouseEvent) => { 111 | const target = event.target as HTMLElement; 112 | const text = target.textContent || (target as HTMLInputElement).value || ''; 113 | if (target && (text.toLowerCase().includes('cancel') || 114 | target.id.toLowerCase().includes('cancel') || 115 | target.className.toLowerCase().includes('cancel'))) { 116 | log.info("Cancel detected via global click handler"); 117 | callback(); 118 | document.removeEventListener('click', globalClickHandler); 119 | } 120 | }; 121 | 122 | document.addEventListener('click', globalClickHandler); 123 | log.debug("Added global click handler for cancel detection"); 124 | } 125 | }; 126 | 127 | findAndAttachListener(); 128 | } 129 | 130 | export function press_maskeditor_save(app: ComfyApp): void { 131 | const button = get_mask_editor_save_button(app); 132 | if (button instanceof HTMLElement) { 133 | button.click(); 134 | } 135 | } 136 | 137 | export function press_maskeditor_cancel(app: ComfyApp): void { 138 | const button = get_mask_editor_cancel_button(app); 139 | if (button instanceof HTMLElement) { 140 | button.click(); 141 | } 142 | } 143 | 144 | /** 145 | * Uruchamia mask editor z predefiniowaną maską 146 | * @param {Canvas} canvasInstance - Instancja Canvas 147 | * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia 148 | * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) 149 | */ 150 | export const start_mask_editor_with_predefined_mask = withErrorHandling(function(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void { 151 | if (!canvasInstance) { 152 | throw createValidationError('Canvas instance is required', { canvasInstance }); 153 | } 154 | if (!maskImage) { 155 | throw createValidationError('Mask image is required', { maskImage }); 156 | } 157 | 158 | canvasInstance.startMaskEditor(maskImage, sendCleanImage); 159 | }, 'start_mask_editor_with_predefined_mask'); 160 | 161 | /** 162 | * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) 163 | * @param {Canvas} canvasInstance - Instancja Canvas 164 | */ 165 | export const start_mask_editor_auto = withErrorHandling(function(canvasInstance: Canvas): void { 166 | if (!canvasInstance) { 167 | throw createValidationError('Canvas instance is required', { canvasInstance }); 168 | } 169 | canvasInstance.startMaskEditor(null, true); 170 | }, 'start_mask_editor_auto'); 171 | 172 | // Duplikowane funkcje zostały przeniesione do ImageUtils.ts: 173 | // - create_mask_from_image_src -> createMaskFromImageSrc 174 | // - canvas_to_mask_image -> canvasToMaskImage 175 | -------------------------------------------------------------------------------- /js/css/layers_panel.css: -------------------------------------------------------------------------------- 1 | /* Layers Panel Styles */ 2 | .layers-panel { 3 | background: #2a2a2a; 4 | border: 1px solid #3a3a3a; 5 | border-radius: 4px; 6 | padding: 8px; 7 | height: 100%; 8 | overflow: hidden; 9 | font-family: Arial, sans-serif; 10 | font-size: 12px; 11 | color: #ffffff; 12 | user-select: none; 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .layers-panel-header { 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | padding-bottom: 8px; 22 | border-bottom: 1px solid #3a3a3a; 23 | margin-bottom: 8px; 24 | } 25 | 26 | .checkbox-container { 27 | display: flex; 28 | align-items: center; 29 | gap: 8px; 30 | padding: 5px 0; 31 | border-radius: 5px; 32 | cursor: pointer; 33 | transition: background-color 0.2s; 34 | position: relative; 35 | } 36 | 37 | .checkbox-container:hover { 38 | background-color: #4a4a4a; 39 | } 40 | 41 | .checkbox-container input[type="checkbox"] { 42 | position: absolute; 43 | opacity: 0; 44 | cursor: pointer; 45 | height: 0; 46 | width: 0; 47 | } 48 | 49 | .checkbox-container .custom-checkbox { 50 | height: 16px; 51 | width: 16px; 52 | background-color: #2a2a2a; 53 | border: 1px solid #666; 54 | border-radius: 3px; 55 | transition: all 0.2s; 56 | position: relative; 57 | flex-shrink: 0; 58 | } 59 | 60 | .checkbox-container input:checked ~ .custom-checkbox { 61 | background-color: #3a76d6; 62 | border-color: #3a76d6; 63 | } 64 | 65 | .checkbox-container .custom-checkbox::after { 66 | content: ""; 67 | position: absolute; 68 | display: none; 69 | left: 5px; 70 | top: 1px; 71 | width: 4px; 72 | height: 9px; 73 | border: solid white; 74 | border-width: 0 2px 2px 0; 75 | transform: rotate(45deg); 76 | } 77 | 78 | .checkbox-container input:checked ~ .custom-checkbox::after { 79 | display: block; 80 | } 81 | 82 | .checkbox-container input:indeterminate ~ .custom-checkbox { 83 | background-color: #3a76d6; 84 | border-color: #3a76d6; 85 | } 86 | 87 | .checkbox-container input:indeterminate ~ .custom-checkbox::after { 88 | display: block; 89 | content: ""; 90 | position: absolute; 91 | top: 7px; 92 | left: 3px; 93 | width: 8px; 94 | height: 2px; 95 | background-color: white; 96 | border: none; 97 | transform: none; 98 | box-shadow: none; 99 | } 100 | 101 | .checkbox-container:hover { 102 | background-color: #4a4a4a; 103 | } 104 | 105 | .layers-panel-title { 106 | font-weight: bold; 107 | color: #ffffff; 108 | } 109 | 110 | .layers-panel-controls { 111 | display: flex; 112 | gap: 4px; 113 | } 114 | 115 | .layers-btn { 116 | background: #3a3a3a; 117 | border: 1px solid #4a4a4a; 118 | color: #ffffff; 119 | padding: 4px 8px; 120 | border-radius: 3px; 121 | cursor: pointer; 122 | font-size: 11px; 123 | } 124 | 125 | .layers-btn:hover { 126 | background: #4a4a4a; 127 | } 128 | 129 | .layers-btn:active { 130 | background: #5a5a5a; 131 | } 132 | 133 | .layers-btn:disabled { 134 | background: #2a2a2a; 135 | color: #666666; 136 | cursor: not-allowed; 137 | opacity: 0.5; 138 | } 139 | 140 | .layers-btn:disabled:hover { 141 | background: #2a2a2a; 142 | } 143 | 144 | .layers-container { 145 | flex: 1; 146 | overflow-y: auto; 147 | overflow-x: hidden; 148 | } 149 | 150 | .layer-row { 151 | display: flex; 152 | align-items: center; 153 | padding: 6px 4px; 154 | margin-bottom: 2px; 155 | border-radius: 3px; 156 | cursor: pointer; 157 | transition: background-color 0.15s ease; 158 | position: relative; 159 | gap: 6px; 160 | } 161 | 162 | .layer-row:hover { 163 | background: rgba(255, 255, 255, 0.05); 164 | } 165 | 166 | .layer-row.selected { 167 | background: #2d5aa0 !important; 168 | box-shadow: inset 0 0 0 1px #4a7bc8; 169 | } 170 | 171 | .layer-row.dragging { 172 | opacity: 0.6; 173 | } 174 | 175 | .layer-thumbnail { 176 | width: 48px; 177 | height: 48px; 178 | border: 1px solid #4a4a4a; 179 | border-radius: 2px; 180 | background: transparent; 181 | position: relative; 182 | flex-shrink: 0; 183 | overflow: hidden; 184 | } 185 | 186 | .layer-thumbnail canvas { 187 | width: 100%; 188 | height: 100%; 189 | display: block; 190 | } 191 | 192 | .layer-thumbnail::before { 193 | content: ''; 194 | position: absolute; 195 | top: 0; 196 | left: 0; 197 | right: 0; 198 | bottom: 0; 199 | background-image: 200 | linear-gradient(45deg, #555 25%, transparent 25%), 201 | linear-gradient(-45deg, #555 25%, transparent 25%), 202 | linear-gradient(45deg, transparent 75%, #555 75%), 203 | linear-gradient(-45deg, transparent 75%, #555 75%); 204 | background-size: 8px 8px; 205 | background-position: 0 0, 0 4px, 4px -4px, -4px 0px; 206 | z-index: 1; 207 | } 208 | 209 | .layer-thumbnail canvas { 210 | position: relative; 211 | z-index: 2; 212 | } 213 | 214 | .layer-name { 215 | flex: 1; 216 | min-width: 0; 217 | overflow: hidden; 218 | text-overflow: ellipsis; 219 | white-space: nowrap; 220 | padding: 2px 4px; 221 | border-radius: 2px; 222 | color: #ffffff; 223 | } 224 | 225 | .layer-name.editing { 226 | background: #4a4a4a; 227 | border: 1px solid #6a6a6a; 228 | outline: none; 229 | color: #ffffff; 230 | } 231 | 232 | .layer-name input { 233 | background: transparent; 234 | border: none; 235 | color: #ffffff; 236 | font-size: 12px; 237 | width: 100%; 238 | outline: none; 239 | } 240 | 241 | .drag-insertion-line { 242 | position: absolute; 243 | left: 0; 244 | right: 0; 245 | height: 2px; 246 | background: #4a7bc8; 247 | border-radius: 1px; 248 | z-index: 1000; 249 | box-shadow: 0 0 4px rgba(74, 123, 200, 0.6); 250 | } 251 | 252 | .layers-container::-webkit-scrollbar { 253 | width: 6px; 254 | } 255 | 256 | .layers-container::-webkit-scrollbar-track { 257 | background: #2a2a2a; 258 | } 259 | 260 | .layers-container::-webkit-scrollbar-thumb { 261 | background: #4a4a4a; 262 | border-radius: 3px; 263 | } 264 | 265 | .layers-container::-webkit-scrollbar-thumb:hover { 266 | background: #5a5a5a; 267 | } 268 | 269 | .layer-visibility-toggle { 270 | width: 20px; 271 | height: 20px; 272 | display: flex; 273 | align-items: center; 274 | justify-content: center; 275 | cursor: pointer; 276 | border-radius: 2px; 277 | font-size: 14px; 278 | flex-shrink: 0; 279 | transition: background-color 0.15s ease; 280 | } 281 | 282 | .layer-visibility-toggle:hover { 283 | background: rgba(255, 255, 255, 0.1); 284 | } 285 | 286 | /* Icon container styles */ 287 | .layers-panel .icon-container { 288 | display: flex; 289 | align-items: center; 290 | justify-content: center; 291 | } 292 | 293 | .layers-panel .icon-container img { 294 | filter: brightness(0) invert(1); 295 | } 296 | 297 | .layers-panel .icon-container.visibility-hidden { 298 | opacity: 0.5; 299 | } 300 | 301 | .layers-panel .icon-container.visibility-hidden img { 302 | filter: brightness(0) invert(1); 303 | opacity: 0.3; 304 | } 305 | 306 | .layers-panel .icon-container.fallback-text { 307 | font-size: 10px; 308 | color: #888888; 309 | } 310 | -------------------------------------------------------------------------------- /src/css/layers_panel.css: -------------------------------------------------------------------------------- 1 | /* Layers Panel Styles */ 2 | .layers-panel { 3 | background: #2a2a2a; 4 | border: 1px solid #3a3a3a; 5 | border-radius: 4px; 6 | padding: 8px; 7 | height: 100%; 8 | overflow: hidden; 9 | font-family: Arial, sans-serif; 10 | font-size: 12px; 11 | color: #ffffff; 12 | user-select: none; 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .layers-panel-header { 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | padding-bottom: 8px; 22 | border-bottom: 1px solid #3a3a3a; 23 | margin-bottom: 8px; 24 | } 25 | 26 | .checkbox-container { 27 | display: flex; 28 | align-items: center; 29 | gap: 8px; 30 | padding: 5px 0; 31 | border-radius: 5px; 32 | cursor: pointer; 33 | transition: background-color 0.2s; 34 | position: relative; 35 | } 36 | 37 | .checkbox-container:hover { 38 | background-color: #4a4a4a; 39 | } 40 | 41 | .checkbox-container input[type="checkbox"] { 42 | position: absolute; 43 | opacity: 0; 44 | cursor: pointer; 45 | height: 0; 46 | width: 0; 47 | } 48 | 49 | .checkbox-container .custom-checkbox { 50 | height: 16px; 51 | width: 16px; 52 | background-color: #2a2a2a; 53 | border: 1px solid #666; 54 | border-radius: 3px; 55 | transition: all 0.2s; 56 | position: relative; 57 | flex-shrink: 0; 58 | } 59 | 60 | .checkbox-container input:checked ~ .custom-checkbox { 61 | background-color: #3a76d6; 62 | border-color: #3a76d6; 63 | } 64 | 65 | .checkbox-container .custom-checkbox::after { 66 | content: ""; 67 | position: absolute; 68 | display: none; 69 | left: 5px; 70 | top: 1px; 71 | width: 4px; 72 | height: 9px; 73 | border: solid white; 74 | border-width: 0 2px 2px 0; 75 | transform: rotate(45deg); 76 | } 77 | 78 | .checkbox-container input:checked ~ .custom-checkbox::after { 79 | display: block; 80 | } 81 | 82 | .checkbox-container input:indeterminate ~ .custom-checkbox { 83 | background-color: #3a76d6; 84 | border-color: #3a76d6; 85 | } 86 | 87 | .checkbox-container input:indeterminate ~ .custom-checkbox::after { 88 | display: block; 89 | content: ""; 90 | position: absolute; 91 | top: 7px; 92 | left: 3px; 93 | width: 8px; 94 | height: 2px; 95 | background-color: white; 96 | border: none; 97 | transform: none; 98 | box-shadow: none; 99 | } 100 | 101 | .checkbox-container:hover { 102 | background-color: #4a4a4a; 103 | } 104 | 105 | .layers-panel-title { 106 | font-weight: bold; 107 | color: #ffffff; 108 | } 109 | 110 | .layers-panel-controls { 111 | display: flex; 112 | gap: 4px; 113 | } 114 | 115 | .layers-btn { 116 | background: #3a3a3a; 117 | border: 1px solid #4a4a4a; 118 | color: #ffffff; 119 | padding: 4px 8px; 120 | border-radius: 3px; 121 | cursor: pointer; 122 | font-size: 11px; 123 | } 124 | 125 | .layers-btn:hover { 126 | background: #4a4a4a; 127 | } 128 | 129 | .layers-btn:active { 130 | background: #5a5a5a; 131 | } 132 | 133 | .layers-btn:disabled { 134 | background: #2a2a2a; 135 | color: #666666; 136 | cursor: not-allowed; 137 | opacity: 0.5; 138 | } 139 | 140 | .layers-btn:disabled:hover { 141 | background: #2a2a2a; 142 | } 143 | 144 | .layers-container { 145 | flex: 1; 146 | overflow-y: auto; 147 | overflow-x: hidden; 148 | } 149 | 150 | .layer-row { 151 | display: flex; 152 | align-items: center; 153 | padding: 6px 4px; 154 | margin-bottom: 2px; 155 | border-radius: 3px; 156 | cursor: pointer; 157 | transition: background-color 0.15s ease; 158 | position: relative; 159 | gap: 6px; 160 | } 161 | 162 | .layer-row:hover { 163 | background: rgba(255, 255, 255, 0.05); 164 | } 165 | 166 | .layer-row.selected { 167 | background: #2d5aa0 !important; 168 | box-shadow: inset 0 0 0 1px #4a7bc8; 169 | } 170 | 171 | .layer-row.dragging { 172 | opacity: 0.6; 173 | } 174 | 175 | .layer-thumbnail { 176 | width: 48px; 177 | height: 48px; 178 | border: 1px solid #4a4a4a; 179 | border-radius: 2px; 180 | background: transparent; 181 | position: relative; 182 | flex-shrink: 0; 183 | overflow: hidden; 184 | } 185 | 186 | .layer-thumbnail canvas { 187 | width: 100%; 188 | height: 100%; 189 | display: block; 190 | } 191 | 192 | .layer-thumbnail::before { 193 | content: ''; 194 | position: absolute; 195 | top: 0; 196 | left: 0; 197 | right: 0; 198 | bottom: 0; 199 | background-image: 200 | linear-gradient(45deg, #555 25%, transparent 25%), 201 | linear-gradient(-45deg, #555 25%, transparent 25%), 202 | linear-gradient(45deg, transparent 75%, #555 75%), 203 | linear-gradient(-45deg, transparent 75%, #555 75%); 204 | background-size: 8px 8px; 205 | background-position: 0 0, 0 4px, 4px -4px, -4px 0px; 206 | z-index: 1; 207 | } 208 | 209 | .layer-thumbnail canvas { 210 | position: relative; 211 | z-index: 2; 212 | } 213 | 214 | .layer-name { 215 | flex: 1; 216 | min-width: 0; 217 | overflow: hidden; 218 | text-overflow: ellipsis; 219 | white-space: nowrap; 220 | padding: 2px 4px; 221 | border-radius: 2px; 222 | color: #ffffff; 223 | } 224 | 225 | .layer-name.editing { 226 | background: #4a4a4a; 227 | border: 1px solid #6a6a6a; 228 | outline: none; 229 | color: #ffffff; 230 | } 231 | 232 | .layer-name input { 233 | background: transparent; 234 | border: none; 235 | color: #ffffff; 236 | font-size: 12px; 237 | width: 100%; 238 | outline: none; 239 | } 240 | 241 | .drag-insertion-line { 242 | position: absolute; 243 | left: 0; 244 | right: 0; 245 | height: 2px; 246 | background: #4a7bc8; 247 | border-radius: 1px; 248 | z-index: 1000; 249 | box-shadow: 0 0 4px rgba(74, 123, 200, 0.6); 250 | } 251 | 252 | .layers-container::-webkit-scrollbar { 253 | width: 6px; 254 | } 255 | 256 | .layers-container::-webkit-scrollbar-track { 257 | background: #2a2a2a; 258 | } 259 | 260 | .layers-container::-webkit-scrollbar-thumb { 261 | background: #4a4a4a; 262 | border-radius: 3px; 263 | } 264 | 265 | .layers-container::-webkit-scrollbar-thumb:hover { 266 | background: #5a5a5a; 267 | } 268 | 269 | .layer-visibility-toggle { 270 | width: 20px; 271 | height: 20px; 272 | display: flex; 273 | align-items: center; 274 | justify-content: center; 275 | cursor: pointer; 276 | border-radius: 2px; 277 | font-size: 14px; 278 | flex-shrink: 0; 279 | transition: background-color 0.15s ease; 280 | } 281 | 282 | .layer-visibility-toggle:hover { 283 | background: rgba(255, 255, 255, 0.1); 284 | } 285 | 286 | /* Icon container styles */ 287 | .layers-panel .icon-container { 288 | display: flex; 289 | align-items: center; 290 | justify-content: center; 291 | } 292 | 293 | .layers-panel .icon-container img { 294 | filter: brightness(0) invert(1); 295 | } 296 | 297 | .layers-panel .icon-container.visibility-hidden { 298 | opacity: 0.5; 299 | } 300 | 301 | .layers-panel .icon-container.visibility-hidden img { 302 | filter: brightness(0) invert(1); 303 | opacity: 0.3; 304 | } 305 | 306 | .layers-panel .icon-container.fallback-text { 307 | font-size: 10px; 308 | color: #888888; 309 | } 310 | -------------------------------------------------------------------------------- /js/utils/PreviewUtils.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; 3 | const log = createModuleLogger('PreviewUtils'); 4 | /** 5 | * Creates a preview image from canvas and updates node 6 | * @param canvas - Canvas object with canvasLayers 7 | * @param node - ComfyUI node to update 8 | * @param options - Preview options 9 | * @returns Promise with created Image element 10 | */ 11 | export const createPreviewFromCanvas = withErrorHandling(async function (canvas, node, options = {}) { 12 | if (!canvas) { 13 | throw createValidationError("Canvas is required", { canvas }); 14 | } 15 | if (!node) { 16 | throw createValidationError("Node is required", { node }); 17 | } 18 | const { includeMask = true, updateNodeImages = true, customBlob } = options; 19 | log.debug('Creating preview from canvas:', { 20 | includeMask, 21 | updateNodeImages, 22 | hasCustomBlob: !!customBlob, 23 | nodeId: node.id 24 | }); 25 | let blob = customBlob || null; 26 | // Get blob from canvas if not provided 27 | if (!blob) { 28 | if (!canvas.canvasLayers) { 29 | throw createValidationError("Canvas does not have canvasLayers", { canvas }); 30 | } 31 | if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') { 32 | blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); 33 | } 34 | else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { 35 | blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); 36 | } 37 | else { 38 | throw createValidationError("Canvas does not support required blob generation methods", { 39 | canvas, 40 | availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers) 41 | }); 42 | } 43 | } 44 | if (!blob) { 45 | throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); 46 | } 47 | // Create preview image 48 | const previewImage = new Image(); 49 | previewImage.src = URL.createObjectURL(blob); 50 | // Wait for image to load 51 | await new Promise((resolve, reject) => { 52 | previewImage.onload = () => { 53 | log.debug("Preview image loaded successfully", { 54 | width: previewImage.width, 55 | height: previewImage.height, 56 | nodeId: node.id 57 | }); 58 | resolve(); 59 | }; 60 | previewImage.onerror = (error) => { 61 | log.error("Failed to load preview image", error); 62 | reject(createValidationError("Failed to load preview image", { error, blob: blob?.size })); 63 | }; 64 | }); 65 | // Update node images if requested 66 | if (updateNodeImages) { 67 | node.imgs = [previewImage]; 68 | log.debug("Node images updated with new preview"); 69 | } 70 | return previewImage; 71 | }, 'createPreviewFromCanvas'); 72 | /** 73 | * Creates a preview image from a blob 74 | * @param blob - Image blob 75 | * @param node - ComfyUI node to update (optional) 76 | * @param updateNodeImages - Whether to update node.imgs (default: false) 77 | * @returns Promise with created Image element 78 | */ 79 | export const createPreviewFromBlob = withErrorHandling(async function (blob, node, updateNodeImages = false) { 80 | if (!blob) { 81 | throw createValidationError("Blob is required", { blob }); 82 | } 83 | if (blob.size === 0) { 84 | throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); 85 | } 86 | log.debug('Creating preview from blob:', { 87 | blobSize: blob.size, 88 | updateNodeImages, 89 | hasNode: !!node 90 | }); 91 | const previewImage = new Image(); 92 | previewImage.src = URL.createObjectURL(blob); 93 | await new Promise((resolve, reject) => { 94 | previewImage.onload = () => { 95 | log.debug("Preview image from blob loaded successfully", { 96 | width: previewImage.width, 97 | height: previewImage.height 98 | }); 99 | resolve(); 100 | }; 101 | previewImage.onerror = (error) => { 102 | log.error("Failed to load preview image from blob", error); 103 | reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size })); 104 | }; 105 | }); 106 | if (updateNodeImages && node) { 107 | node.imgs = [previewImage]; 108 | log.debug("Node images updated with blob preview"); 109 | } 110 | return previewImage; 111 | }, 'createPreviewFromBlob'); 112 | /** 113 | * Updates node preview after canvas changes 114 | * @param canvas - Canvas object 115 | * @param node - ComfyUI node 116 | * @param includeMask - Whether to include mask in preview 117 | * @returns Promise with updated preview image 118 | */ 119 | export const updateNodePreview = withErrorHandling(async function (canvas, node, includeMask = true) { 120 | if (!canvas) { 121 | throw createValidationError("Canvas is required", { canvas }); 122 | } 123 | if (!node) { 124 | throw createValidationError("Node is required", { node }); 125 | } 126 | log.info('Updating node preview:', { 127 | nodeId: node.id, 128 | includeMask 129 | }); 130 | // Trigger canvas render and save state 131 | if (typeof canvas.render === 'function') { 132 | canvas.render(); 133 | } 134 | if (typeof canvas.saveState === 'function') { 135 | canvas.saveState(); 136 | } 137 | // Create new preview 138 | const previewImage = await createPreviewFromCanvas(canvas, node, { 139 | includeMask, 140 | updateNodeImages: true 141 | }); 142 | log.info('Node preview updated successfully'); 143 | return previewImage; 144 | }, 'updateNodePreview'); 145 | /** 146 | * Clears node preview images 147 | * @param node - ComfyUI node 148 | */ 149 | export function clearNodePreview(node) { 150 | log.debug('Clearing node preview:', { nodeId: node.id }); 151 | node.imgs = []; 152 | } 153 | /** 154 | * Checks if node has preview images 155 | * @param node - ComfyUI node 156 | * @returns True if node has preview images 157 | */ 158 | export function hasNodePreview(node) { 159 | return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); 160 | } 161 | /** 162 | * Gets the current preview image from node 163 | * @param node - ComfyUI node 164 | * @returns Current preview image or null 165 | */ 166 | export function getCurrentPreview(node) { 167 | if (hasNodePreview(node) && node.imgs) { 168 | return node.imgs[0]; 169 | } 170 | return null; 171 | } 172 | /** 173 | * Creates a preview with custom processing 174 | * @param canvas - Canvas object 175 | * @param node - ComfyUI node 176 | * @param processor - Custom processing function that takes canvas and returns blob 177 | * @returns Promise with processed preview image 178 | */ 179 | export const createCustomPreview = withErrorHandling(async function (canvas, node, processor) { 180 | if (!canvas) { 181 | throw createValidationError("Canvas is required", { canvas }); 182 | } 183 | if (!node) { 184 | throw createValidationError("Node is required", { node }); 185 | } 186 | if (!processor || typeof processor !== 'function') { 187 | throw createValidationError("Processor function is required", { processor }); 188 | } 189 | log.debug('Creating custom preview:', { nodeId: node.id }); 190 | const blob = await processor(canvas); 191 | return createPreviewFromBlob(blob, node, true); 192 | }, 'createCustomPreview'); 193 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import {createModuleLogger} from "./utils/LoggerUtils.js"; 2 | 3 | const log = createModuleLogger('db'); 4 | 5 | const DB_NAME = 'CanvasNodeDB'; 6 | const STATE_STORE_NAME = 'CanvasState'; 7 | const IMAGE_STORE_NAME = 'CanvasImages'; 8 | const DB_VERSION = 3; 9 | 10 | let db: IDBDatabase | null = null; 11 | 12 | type DBRequestOperation = 'get' | 'put' | 'delete' | 'clear'; 13 | 14 | interface CanvasStateDB { 15 | id: string; 16 | state: any; 17 | } 18 | 19 | interface CanvasImageDB { 20 | imageId: string; 21 | imageSrc: string; 22 | } 23 | 24 | /** 25 | * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów 26 | * @param {IDBObjectStore} store - Store IndexedDB 27 | * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear) 28 | * @param {any} data - Dane dla operacji (opcjonalne) 29 | * @param {string} errorMessage - Wiadomość błędu 30 | * @returns {Promise} Promise z wynikiem operacji 31 | */ 32 | function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise { 33 | return new Promise((resolve, reject) => { 34 | let request: IDBRequest; 35 | switch (operation) { 36 | case 'get': 37 | request = store.get(data); 38 | break; 39 | case 'put': 40 | request = store.put(data); 41 | break; 42 | case 'delete': 43 | request = store.delete(data); 44 | break; 45 | case 'clear': 46 | request = store.clear(); 47 | break; 48 | default: 49 | reject(new Error(`Unknown operation: ${operation}`)); 50 | return; 51 | } 52 | 53 | request.onerror = (event) => { 54 | log.error(errorMessage, (event.target as IDBRequest).error); 55 | reject(errorMessage); 56 | }; 57 | 58 | request.onsuccess = (event) => { 59 | resolve((event.target as IDBRequest).result); 60 | }; 61 | }); 62 | } 63 | 64 | function openDB(): Promise { 65 | return new Promise((resolve, reject) => { 66 | if (db) { 67 | resolve(db); 68 | return; 69 | } 70 | 71 | log.info("Opening IndexedDB..."); 72 | const request = indexedDB.open(DB_NAME, DB_VERSION); 73 | 74 | request.onerror = (event) => { 75 | log.error("IndexedDB error:", (event.target as IDBOpenDBRequest).error); 76 | reject("Error opening IndexedDB."); 77 | }; 78 | 79 | request.onsuccess = (event) => { 80 | db = (event.target as IDBOpenDBRequest).result; 81 | log.info("IndexedDB opened successfully."); 82 | resolve(db); 83 | }; 84 | 85 | request.onupgradeneeded = (event) => { 86 | log.info("Upgrading IndexedDB..."); 87 | const dbInstance = (event.target as IDBOpenDBRequest).result; 88 | if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) { 89 | dbInstance.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); 90 | log.info("Object store created:", STATE_STORE_NAME); 91 | } 92 | if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) { 93 | dbInstance.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'}); 94 | log.info("Object store created:", IMAGE_STORE_NAME); 95 | } 96 | }; 97 | }); 98 | } 99 | 100 | export async function getCanvasState(id: string): Promise { 101 | log.info(`Getting state for id: ${id}`); 102 | const db = await openDB(); 103 | const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); 104 | const store = transaction.objectStore(STATE_STORE_NAME); 105 | 106 | const result = await createDBRequest(store, 'get', id, "Error getting canvas state") as CanvasStateDB; 107 | log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); 108 | return result ? result.state : null; 109 | } 110 | 111 | export async function setCanvasState(id: string, state: any): Promise { 112 | log.info(`Setting state for id: ${id}`); 113 | const db = await openDB(); 114 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 115 | const store = transaction.objectStore(STATE_STORE_NAME); 116 | 117 | await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); 118 | log.debug(`Set success for id: ${id}`); 119 | } 120 | 121 | export async function removeCanvasState(id: string): Promise { 122 | log.info(`Removing state for id: ${id}`); 123 | const db = await openDB(); 124 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 125 | const store = transaction.objectStore(STATE_STORE_NAME); 126 | 127 | await createDBRequest(store, 'delete', id, "Error removing canvas state"); 128 | log.debug(`Remove success for id: ${id}`); 129 | } 130 | 131 | export async function saveImage(imageId: string, imageSrc: string | ImageBitmap): Promise { 132 | log.info(`Saving image with id: ${imageId}`); 133 | const db = await openDB(); 134 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); 135 | const store = transaction.objectStore(IMAGE_STORE_NAME); 136 | 137 | await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image"); 138 | log.debug(`Image saved successfully for id: ${imageId}`); 139 | } 140 | 141 | export async function getImage(imageId: string): Promise { 142 | log.info(`Getting image with id: ${imageId}`); 143 | const db = await openDB(); 144 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); 145 | const store = transaction.objectStore(IMAGE_STORE_NAME); 146 | 147 | const result = await createDBRequest(store, 'get', imageId, "Error getting image") as CanvasImageDB; 148 | log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); 149 | return result ? result.imageSrc : null; 150 | } 151 | 152 | export async function removeImage(imageId: string): Promise { 153 | log.info(`Removing image with id: ${imageId}`); 154 | const db = await openDB(); 155 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); 156 | const store = transaction.objectStore(IMAGE_STORE_NAME); 157 | 158 | await createDBRequest(store, 'delete', imageId, "Error removing image"); 159 | log.debug(`Remove image success for id: ${imageId}`); 160 | } 161 | 162 | export async function getAllImageIds(): Promise { 163 | log.info("Getting all image IDs..."); 164 | const db = await openDB(); 165 | const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); 166 | const store = transaction.objectStore(IMAGE_STORE_NAME); 167 | 168 | return new Promise((resolve, reject) => { 169 | const request = store.getAllKeys(); 170 | 171 | request.onerror = (event) => { 172 | log.error("Error getting all image IDs:", (event.target as IDBRequest).error); 173 | reject("Error getting all image IDs"); 174 | }; 175 | 176 | request.onsuccess = (event) => { 177 | const imageIds = (event.target as IDBRequest).result; 178 | log.debug(`Found ${imageIds.length} image IDs in database`); 179 | resolve(imageIds); 180 | }; 181 | }); 182 | } 183 | 184 | export async function clearAllCanvasStates(): Promise { 185 | log.info("Clearing all canvas states..."); 186 | const db = await openDB(); 187 | const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); 188 | const store = transaction.objectStore(STATE_STORE_NAME); 189 | 190 | await createDBRequest(store, 'clear', null, "Error clearing canvas states"); 191 | log.info("All canvas states cleared successfully."); 192 | } 193 | -------------------------------------------------------------------------------- /js/utils/MaskProcessingUtils.js: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | import { createCanvas } from "./CommonUtils.js"; 3 | import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; 4 | const log = createModuleLogger('MaskProcessingUtils'); 5 | /** 6 | * Processes an image to create a mask with inverted alpha channel 7 | * @param sourceImage - Source image or canvas element 8 | * @param options - Processing options 9 | * @returns Promise with processed mask as HTMLCanvasElement 10 | */ 11 | export const processImageToMask = withErrorHandling(async function (sourceImage, options = {}) { 12 | if (!sourceImage) { 13 | throw createValidationError("Source image is required", { sourceImage }); 14 | } 15 | const { targetWidth = sourceImage.width, targetHeight = sourceImage.height, invertAlpha = true, maskColor = { r: 255, g: 255, b: 255 } } = options; 16 | log.debug('Processing image to mask:', { 17 | sourceSize: { width: sourceImage.width, height: sourceImage.height }, 18 | targetSize: { width: targetWidth, height: targetHeight }, 19 | invertAlpha, 20 | maskColor 21 | }); 22 | // Create temporary canvas for processing 23 | const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true }); 24 | if (!tempCtx) { 25 | throw createValidationError("Failed to get 2D context for mask processing"); 26 | } 27 | // Draw the source image 28 | tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); 29 | // Get image data for processing 30 | const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); 31 | const data = imageData.data; 32 | // Process pixels to create mask 33 | for (let i = 0; i < data.length; i += 4) { 34 | const originalAlpha = data[i + 3]; 35 | // Set RGB to mask color 36 | data[i] = maskColor.r; // Red 37 | data[i + 1] = maskColor.g; // Green 38 | data[i + 2] = maskColor.b; // Blue 39 | // Handle alpha channel 40 | if (invertAlpha) { 41 | data[i + 3] = 255 - originalAlpha; // Invert alpha 42 | } 43 | else { 44 | data[i + 3] = originalAlpha; // Keep original alpha 45 | } 46 | } 47 | // Put processed data back to canvas 48 | tempCtx.putImageData(imageData, 0, 0); 49 | log.debug('Mask processing completed'); 50 | return tempCanvas; 51 | }, 'processImageToMask'); 52 | /** 53 | * Processes image data with custom pixel transformation 54 | * @param sourceImage - Source image or canvas element 55 | * @param pixelTransform - Custom pixel transformation function 56 | * @param options - Processing options 57 | * @returns Promise with processed image as HTMLCanvasElement 58 | */ 59 | export const processImageWithTransform = withErrorHandling(async function (sourceImage, pixelTransform, options = {}) { 60 | if (!sourceImage) { 61 | throw createValidationError("Source image is required", { sourceImage }); 62 | } 63 | if (!pixelTransform || typeof pixelTransform !== 'function') { 64 | throw createValidationError("Pixel transform function is required", { pixelTransform }); 65 | } 66 | const { targetWidth = sourceImage.width, targetHeight = sourceImage.height } = options; 67 | const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true }); 68 | if (!tempCtx) { 69 | throw createValidationError("Failed to get 2D context for image processing"); 70 | } 71 | tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight); 72 | const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); 73 | const data = imageData.data; 74 | for (let i = 0; i < data.length; i += 4) { 75 | const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4); 76 | data[i] = r; 77 | data[i + 1] = g; 78 | data[i + 2] = b; 79 | data[i + 3] = a; 80 | } 81 | tempCtx.putImageData(imageData, 0, 0); 82 | return tempCanvas; 83 | }, 'processImageWithTransform'); 84 | /** 85 | * Crops an image to a specific region 86 | * @param sourceImage - Source image or canvas 87 | * @param cropArea - Crop area {x, y, width, height} 88 | * @returns Promise with cropped image as HTMLCanvasElement 89 | */ 90 | export const cropImage = withErrorHandling(async function (sourceImage, cropArea) { 91 | if (!sourceImage) { 92 | throw createValidationError("Source image is required", { sourceImage }); 93 | } 94 | if (!cropArea || typeof cropArea !== 'object') { 95 | throw createValidationError("Crop area is required", { cropArea }); 96 | } 97 | const { x, y, width, height } = cropArea; 98 | if (width <= 0 || height <= 0) { 99 | throw createValidationError("Crop area must have positive width and height", { cropArea }); 100 | } 101 | log.debug('Cropping image:', { 102 | sourceSize: { width: sourceImage.width, height: sourceImage.height }, 103 | cropArea 104 | }); 105 | const { canvas, ctx } = createCanvas(width, height); 106 | if (!ctx) { 107 | throw createValidationError("Failed to get 2D context for image cropping"); 108 | } 109 | ctx.drawImage(sourceImage, x, y, width, height, // Source rectangle 110 | 0, 0, width, height // Destination rectangle 111 | ); 112 | return canvas; 113 | }, 'cropImage'); 114 | /** 115 | * Applies a mask to an image using viewport positioning 116 | * @param maskImage - Mask image or canvas 117 | * @param targetWidth - Target viewport width 118 | * @param targetHeight - Target viewport height 119 | * @param viewportOffset - Viewport offset {x, y} 120 | * @param maskColor - Mask color (default: white) 121 | * @returns Promise with processed mask for viewport 122 | */ 123 | export const processMaskForViewport = withErrorHandling(async function (maskImage, targetWidth, targetHeight, viewportOffset, maskColor = { r: 255, g: 255, b: 255 }) { 124 | if (!maskImage) { 125 | throw createValidationError("Mask image is required", { maskImage }); 126 | } 127 | if (!viewportOffset || typeof viewportOffset !== 'object') { 128 | throw createValidationError("Viewport offset is required", { viewportOffset }); 129 | } 130 | if (targetWidth <= 0 || targetHeight <= 0) { 131 | throw createValidationError("Target dimensions must be positive", { targetWidth, targetHeight }); 132 | } 133 | log.debug("Processing mask for viewport:", { 134 | sourceSize: { width: maskImage.width, height: maskImage.height }, 135 | targetSize: { width: targetWidth, height: targetHeight }, 136 | viewportOffset 137 | }); 138 | const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true }); 139 | if (!tempCtx) { 140 | throw createValidationError("Failed to get 2D context for viewport mask processing"); 141 | } 142 | // Calculate source coordinates based on viewport offset 143 | const sourceX = -viewportOffset.x; 144 | const sourceY = -viewportOffset.y; 145 | // Draw the mask with viewport cropping 146 | tempCtx.drawImage(maskImage, // Source: full mask from "output area" 147 | sourceX, // sx: Real X coordinate on large mask 148 | sourceY, // sy: Real Y coordinate on large mask 149 | targetWidth, // sWidth: Width of cropped fragment 150 | targetHeight, // sHeight: Height of cropped fragment 151 | 0, // dx: Where to paste in target canvas (always 0) 152 | 0, // dy: Where to paste in target canvas (always 0) 153 | targetWidth, // dWidth: Width of pasted image 154 | targetHeight // dHeight: Height of pasted image 155 | ); 156 | // Apply mask color 157 | const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); 158 | const data = imageData.data; 159 | for (let i = 0; i < data.length; i += 4) { 160 | const alpha = data[i + 3]; 161 | if (alpha > 0) { 162 | data[i] = maskColor.r; 163 | data[i + 1] = maskColor.g; 164 | data[i + 2] = maskColor.b; 165 | } 166 | } 167 | tempCtx.putImageData(imageData, 0, 0); 168 | log.debug("Viewport mask processing completed"); 169 | return tempCanvas; 170 | }, 'processMaskForViewport'); 171 | -------------------------------------------------------------------------------- /src/utils/PreviewUtils.ts: -------------------------------------------------------------------------------- 1 | import { createModuleLogger } from "./LoggerUtils.js"; 2 | import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; 3 | import type { ComfyNode } from '../types'; 4 | 5 | const log = createModuleLogger('PreviewUtils'); 6 | 7 | /** 8 | * Utility functions for creating and managing preview images 9 | */ 10 | 11 | export interface PreviewOptions { 12 | /** Whether to include mask in the preview (default: true) */ 13 | includeMask?: boolean; 14 | /** Whether to update node.imgs array (default: true) */ 15 | updateNodeImages?: boolean; 16 | /** Custom blob source instead of canvas */ 17 | customBlob?: Blob; 18 | } 19 | 20 | /** 21 | * Creates a preview image from canvas and updates node 22 | * @param canvas - Canvas object with canvasLayers 23 | * @param node - ComfyUI node to update 24 | * @param options - Preview options 25 | * @returns Promise with created Image element 26 | */ 27 | export const createPreviewFromCanvas = withErrorHandling(async function( 28 | canvas: any, 29 | node: ComfyNode, 30 | options: PreviewOptions = {} 31 | ): Promise { 32 | if (!canvas) { 33 | throw createValidationError("Canvas is required", { canvas }); 34 | } 35 | if (!node) { 36 | throw createValidationError("Node is required", { node }); 37 | } 38 | 39 | const { 40 | includeMask = true, 41 | updateNodeImages = true, 42 | customBlob 43 | } = options; 44 | 45 | log.debug('Creating preview from canvas:', { 46 | includeMask, 47 | updateNodeImages, 48 | hasCustomBlob: !!customBlob, 49 | nodeId: node.id 50 | }); 51 | 52 | let blob: Blob | null = customBlob || null; 53 | 54 | // Get blob from canvas if not provided 55 | if (!blob) { 56 | if (!canvas.canvasLayers) { 57 | throw createValidationError("Canvas does not have canvasLayers", { canvas }); 58 | } 59 | 60 | if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') { 61 | blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); 62 | } else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') { 63 | blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); 64 | } else { 65 | throw createValidationError("Canvas does not support required blob generation methods", { 66 | canvas, 67 | availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers) 68 | }); 69 | } 70 | } 71 | 72 | if (!blob) { 73 | throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); 74 | } 75 | 76 | // Create preview image 77 | const previewImage = new Image(); 78 | previewImage.src = URL.createObjectURL(blob); 79 | 80 | // Wait for image to load 81 | await new Promise((resolve, reject) => { 82 | previewImage.onload = () => { 83 | log.debug("Preview image loaded successfully", { 84 | width: previewImage.width, 85 | height: previewImage.height, 86 | nodeId: node.id 87 | }); 88 | resolve(); 89 | }; 90 | previewImage.onerror = (error) => { 91 | log.error("Failed to load preview image", error); 92 | reject(createValidationError("Failed to load preview image", { error, blob: blob?.size })); 93 | }; 94 | }); 95 | 96 | // Update node images if requested 97 | if (updateNodeImages) { 98 | node.imgs = [previewImage]; 99 | log.debug("Node images updated with new preview"); 100 | } 101 | 102 | return previewImage; 103 | }, 'createPreviewFromCanvas'); 104 | 105 | /** 106 | * Creates a preview image from a blob 107 | * @param blob - Image blob 108 | * @param node - ComfyUI node to update (optional) 109 | * @param updateNodeImages - Whether to update node.imgs (default: false) 110 | * @returns Promise with created Image element 111 | */ 112 | export const createPreviewFromBlob = withErrorHandling(async function( 113 | blob: Blob, 114 | node?: ComfyNode, 115 | updateNodeImages: boolean = false 116 | ): Promise { 117 | if (!blob) { 118 | throw createValidationError("Blob is required", { blob }); 119 | } 120 | if (blob.size === 0) { 121 | throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); 122 | } 123 | 124 | log.debug('Creating preview from blob:', { 125 | blobSize: blob.size, 126 | updateNodeImages, 127 | hasNode: !!node 128 | }); 129 | 130 | const previewImage = new Image(); 131 | previewImage.src = URL.createObjectURL(blob); 132 | 133 | await new Promise((resolve, reject) => { 134 | previewImage.onload = () => { 135 | log.debug("Preview image from blob loaded successfully", { 136 | width: previewImage.width, 137 | height: previewImage.height 138 | }); 139 | resolve(); 140 | }; 141 | previewImage.onerror = (error) => { 142 | log.error("Failed to load preview image from blob", error); 143 | reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size })); 144 | }; 145 | }); 146 | 147 | if (updateNodeImages && node) { 148 | node.imgs = [previewImage]; 149 | log.debug("Node images updated with blob preview"); 150 | } 151 | 152 | return previewImage; 153 | }, 'createPreviewFromBlob'); 154 | 155 | /** 156 | * Updates node preview after canvas changes 157 | * @param canvas - Canvas object 158 | * @param node - ComfyUI node 159 | * @param includeMask - Whether to include mask in preview 160 | * @returns Promise with updated preview image 161 | */ 162 | export const updateNodePreview = withErrorHandling(async function( 163 | canvas: any, 164 | node: ComfyNode, 165 | includeMask: boolean = true 166 | ): Promise { 167 | if (!canvas) { 168 | throw createValidationError("Canvas is required", { canvas }); 169 | } 170 | if (!node) { 171 | throw createValidationError("Node is required", { node }); 172 | } 173 | 174 | log.info('Updating node preview:', { 175 | nodeId: node.id, 176 | includeMask 177 | }); 178 | 179 | // Trigger canvas render and save state 180 | if (typeof canvas.render === 'function') { 181 | canvas.render(); 182 | } 183 | 184 | if (typeof canvas.saveState === 'function') { 185 | canvas.saveState(); 186 | } 187 | 188 | // Create new preview 189 | const previewImage = await createPreviewFromCanvas(canvas, node, { 190 | includeMask, 191 | updateNodeImages: true 192 | }); 193 | 194 | log.info('Node preview updated successfully'); 195 | return previewImage; 196 | }, 'updateNodePreview'); 197 | 198 | /** 199 | * Clears node preview images 200 | * @param node - ComfyUI node 201 | */ 202 | export function clearNodePreview(node: ComfyNode): void { 203 | log.debug('Clearing node preview:', { nodeId: node.id }); 204 | node.imgs = []; 205 | } 206 | 207 | /** 208 | * Checks if node has preview images 209 | * @param node - ComfyUI node 210 | * @returns True if node has preview images 211 | */ 212 | export function hasNodePreview(node: ComfyNode): boolean { 213 | return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); 214 | } 215 | 216 | /** 217 | * Gets the current preview image from node 218 | * @param node - ComfyUI node 219 | * @returns Current preview image or null 220 | */ 221 | export function getCurrentPreview(node: ComfyNode): HTMLImageElement | null { 222 | if (hasNodePreview(node) && node.imgs) { 223 | return node.imgs[0]; 224 | } 225 | return null; 226 | } 227 | 228 | /** 229 | * Creates a preview with custom processing 230 | * @param canvas - Canvas object 231 | * @param node - ComfyUI node 232 | * @param processor - Custom processing function that takes canvas and returns blob 233 | * @returns Promise with processed preview image 234 | */ 235 | export const createCustomPreview = withErrorHandling(async function( 236 | canvas: any, 237 | node: ComfyNode, 238 | processor: (canvas: any) => Promise 239 | ): Promise { 240 | if (!canvas) { 241 | throw createValidationError("Canvas is required", { canvas }); 242 | } 243 | if (!node) { 244 | throw createValidationError("Node is required", { node }); 245 | } 246 | if (!processor || typeof processor !== 'function') { 247 | throw createValidationError("Processor function is required", { processor }); 248 | } 249 | 250 | log.debug('Creating custom preview:', { nodeId: node.id }); 251 | 252 | const blob = await processor(canvas); 253 | return createPreviewFromBlob(blob, node, true); 254 | }, 'createCustomPreview'); 255 | --------------------------------------------------------------------------------