├── 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 | [](https://comfy.org)
7 |
8 | ```
9 |
10 | **HTML**
11 | ```html
12 |
13 | ```
14 |
--------------------------------------------------------------------------------
/CLONE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Markdown**
4 |
5 | ```markdown
6 | [](https://github.com/MShawon/github-clone-count-badge)
7 |
8 | ```
9 |
10 | **HTML**
11 | ```html
12 |
13 | ```
14 |
--------------------------------------------------------------------------------
/js/templates/mask_shortcuts.html:
--------------------------------------------------------------------------------
1 |
Mask Mode
2 |
3 | | Click + Drag | Paint on the mask |
4 | | Middle Mouse Button + Drag | Pan canvas view |
5 | | Mouse Wheel | Zoom view in/out |
6 | | Brush Controls | Use sliders to control brush Size, Strength, and Hardness |
7 | | Clear Mask | Remove the entire mask |
8 | | Exit Mode | Click the "Draw Mask" button again |
9 |
10 |
--------------------------------------------------------------------------------
/src/templates/mask_shortcuts.html:
--------------------------------------------------------------------------------
1 | Mask Mode
2 |
3 | | Click + Drag | Paint on the mask |
4 | | Middle Mouse Button + Drag | Pan canvas view |
5 | | Mouse Wheel | Zoom view in/out |
6 | | Brush Controls | Use sliders to control brush Size, Strength, and Hardness |
7 | | Clear Mask | Remove the entire mask |
8 | | Exit Mode | Click the "Draw Mask" button again |
9 |
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 | | Ctrl + C | Copy selected layers to internal clipboard + ComfyUI Clipspace as flattened image |
4 | | Ctrl + V | Priority: |
5 | | 1️⃣ Internal clipboard (copied layers) |
6 | | 2️⃣ ComfyUI Clipspace (workflow images) |
7 | | 3️⃣ System clipboard (fallback) |
8 | | Paste Image | Same as Ctrl+V but respects fit_on_add setting |
9 | | Drag & Drop | Load images directly from files |
10 |
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 | | Ctrl + C | Copy selected layers to internal clipboard + ComfyUI Clipspace as flattened image |
4 | | Ctrl + V | Priority: |
5 | | 1️⃣ Internal clipboard (copied layers) |
6 | | 2️⃣ ComfyUI Clipspace (workflow images) |
7 | | 3️⃣ System clipboard (fallback) |
8 | | Paste Image | Same as Ctrl+V but respects fit_on_add setting |
9 | | Drag & Drop | Load images directly from files |
10 |
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 | | Ctrl + C | Copy selected layers to internal clipboard + system clipboard as flattened image |
4 | | Ctrl + V | Priority: |
5 | | 1️⃣ Internal clipboard (copied layers) |
6 | | 2️⃣ System clipboard (images, screenshots) |
7 | | 3️⃣ System clipboard (file paths, URLs) |
8 | | Paste Image | Same as Ctrl+V but respects fit_on_add setting |
9 | | Drag & Drop | Load images directly from files |
10 |
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 | | Ctrl + C | Copy selected layers to internal clipboard + system clipboard as flattened image |
4 | | Ctrl + V | Priority: |
5 | | 1️⃣ Internal clipboard (copied layers) |
6 | | 2️⃣ System clipboard (images, screenshots) |
7 | | 3️⃣ System clipboard (file paths, URLs) |
8 | | Paste Image | Same as Ctrl+V but respects fit_on_add setting |
9 | | Drag & Drop | Load images directly from files |
10 |
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 | | Click + Drag | Pan canvas view |
4 | | Mouse Wheel | Zoom view in/out |
5 | | Shift + Click (background) | Start resizing canvas area |
6 | | Shift + Ctrl + Click | Start moving entire canvas |
7 | | Shift + S + Left Click | Draw custom shape for output area |
8 | | Single Click (background) | Deselect all layers |
9 | | Esc | Close fullscreen editor mode |
10 |
11 |
12 | Clipboard & I/O
13 |
14 | | Ctrl + C | Copy selected layer(s) |
15 | | Ctrl + V | Paste from clipboard (image or internal layers) |
16 | | Drag & Drop Image File | Add image as a new layer |
17 |
18 |
19 | Layer Interaction
20 |
21 | | Click + Drag | Move selected layer(s) |
22 | | Ctrl + Click | Add/Remove layer from selection |
23 | | Alt + Drag | Clone selected layer(s) |
24 | | Right Click | Show blend mode & opacity menu |
25 | | Mouse Wheel | Scale layer (snaps to grid) |
26 | | Ctrl + Mouse Wheel | Fine-scale layer |
27 | | Shift + Mouse Wheel | Rotate layer by 5° steps |
28 | | Shift + Ctrl + Mouse Wheel | Snap rotation to 5° increments |
29 | | Arrow Keys | Nudge layer by 1px |
30 | | Shift + Arrow Keys | Nudge layer by 10px |
31 | | [ or ] | Rotate by 1° |
32 | | Shift + [ or ] | Rotate by 10° |
33 | | Delete | Delete selected layer(s) |
34 |
35 |
36 | Transform Handles (on selected layer)
37 |
38 | | Drag Corner/Side | Resize layer |
39 | | Drag Rotation Handle | Rotate layer |
40 | | Hold Shift | Keep aspect ratio / Snap rotation to 15° |
41 | | Hold Ctrl | Snap to grid |
42 |
43 |
--------------------------------------------------------------------------------
/src/templates/standard_shortcuts.html:
--------------------------------------------------------------------------------
1 | Canvas Control
2 |
3 | | Click + Drag | Pan canvas view |
4 | | Mouse Wheel | Zoom view in/out |
5 | | Shift + Click (background) | Start resizing canvas area |
6 | | Shift + Ctrl + Click | Start moving entire canvas |
7 | | Shift + S + Left Click | Draw custom shape for output area |
8 | | Single Click (background) | Deselect all layers |
9 | | Esc | Close fullscreen editor mode |
10 |
11 |
12 | Clipboard & I/O
13 |
14 | | Ctrl + C | Copy selected layer(s) |
15 | | Ctrl + V | Paste from clipboard (image or internal layers) |
16 | | Drag & Drop Image File | Add image as a new layer |
17 |
18 |
19 | Layer Interaction
20 |
21 | | Click + Drag | Move selected layer(s) |
22 | | Ctrl + Click | Add/Remove layer from selection |
23 | | Alt + Drag | Clone selected layer(s) |
24 | | Right Click | Show blend mode & opacity menu |
25 | | Mouse Wheel | Scale layer (snaps to grid) |
26 | | Ctrl + Mouse Wheel | Fine-scale layer |
27 | | Shift + Mouse Wheel | Rotate layer by 5° steps |
28 | | Shift + Ctrl + Mouse Wheel | Snap rotation to 5° increments |
29 | | Arrow Keys | Nudge layer by 1px |
30 | | Shift + Arrow Keys | Nudge layer by 10px |
31 | | [ or ] | Rotate by 1° |
32 | | Shift + [ or ] | Rotate by 10° |
33 | | Delete | Delete selected layer(s) |
34 |
35 |
36 | Transform Handles (on selected layer)
37 |
38 | | Drag Corner/Side | Resize layer |
39 | | Drag Rotation Handle | Rotate layer |
40 | | Hold Shift | Keep aspect ratio / Snap rotation to 15° |
41 | | Hold Ctrl | Snap to grid |
42 |
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 "[]($repo)" >> CLONE.md
73 | echo '
74 | ```
75 |
76 | **HTML**
77 | ```html' >> CLONE.md
78 | echo "
" >> 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 "[]($repo)" >> LAYERFORGE.md
110 | echo '
111 | ```
112 |
113 | **HTML**
114 | ```html' >> LAYERFORGE.md
115 | echo "
" >> 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 |
--------------------------------------------------------------------------------